1use crate::http_client::{HeaderMap, header};
5
6use crate::backends::common::{CommonBuilderConfig, CommonConfig, RequestConfig};
7use crate::backends::{Page, PageRequest, first_page_url, next_link, run_paginated};
8use crate::version::bump_is_greater;
9use crate::{
10 errors::*,
11 update::{Release, ReleaseAsset, ReleaseUpdate, Releases},
12};
13use serde::Deserialize;
14
15const CANONICAL_AUTH_HOST: &str = "api.github.com";
20
21#[derive(Deserialize)]
25struct AssetDto {
26 name: Option<String>,
27 url: Option<String>,
28 digest: Option<String>,
31}
32
33impl AssetDto {
34 fn into_asset(self) -> Result<ReleaseAsset> {
35 let download_url = self.url.ok_or_else(|| Error::missing_asset_field("url"))?;
36 let name = self
37 .name
38 .ok_or_else(|| Error::missing_asset_field("name"))?;
39 let asset = ReleaseAsset::new(name, download_url);
40 Ok(match self.digest {
41 Some(digest) => asset.with_digest(digest),
42 None => asset,
43 })
44 }
45}
46
47#[derive(Deserialize)]
50struct ReleaseDto {
51 tag_name: Option<String>,
52 created_at: Option<String>,
53 name: Option<String>,
54 body: Option<String>,
55 html_url: Option<String>,
56 assets: Option<Vec<AssetDto>>,
57}
58
59impl ReleaseDto {
60 fn into_release(self, tag_prefix: Option<&str>) -> Result<Release> {
61 let tag = self
62 .tag_name
63 .ok_or_else(|| Error::missing_asset_field("tag_name"))?;
64 let date = self
65 .created_at
66 .ok_or_else(|| Error::missing_asset_field("created_at"))?;
67 let assets = self
68 .assets
69 .ok_or_else(|| Error::missing_asset_field("assets"))?;
70 let name = self.name.unwrap_or_else(|| tag.clone());
71 let assets = assets
72 .into_iter()
73 .map(AssetDto::into_asset)
74 .collect::<Result<Vec<ReleaseAsset>>>()?;
75 let version =
76 crate::backends::common::strip_tag_prefix(&tag, tag_prefix).ok_or_else(|| {
77 crate::backends::common::tag_prefix_mismatch_error(
78 &tag,
79 tag_prefix.unwrap_or_default(),
80 )
81 })?;
82 let mut builder = Release::builder();
83 builder
84 .name(name)
85 .version(version)
86 .date(date)
87 .assets(assets);
88 if let Some(body) = self.body {
89 builder.body(body);
90 }
91 if let Some(url) = self.html_url {
92 builder.release_notes_url(url);
93 }
94 builder
95 .build()
96 .map_err(|e| crate::backends::common::name_tag_in_semver_error(&tag, e))
97 }
98}
99
100#[derive(Clone)]
105#[must_use]
106pub struct ReleaseListBuilder {
107 repo_owner: Option<String>,
108 repo_name: Option<String>,
109 target: Option<String>,
110 auth_token: Option<String>,
111 auth_token_from_env: bool,
113 custom_url: Option<String>,
114 request: RequestConfig,
115}
116
117impl std::fmt::Debug for ReleaseListBuilder {
118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 let Self {
123 repo_owner,
124 repo_name,
125 target,
126 auth_token,
127 auth_token_from_env,
128 custom_url,
129 request,
130 } = self;
131 f.debug_struct("ReleaseListBuilder")
132 .field("repo_owner", repo_owner)
133 .field("repo_name", repo_name)
134 .field("target", target)
135 .field("auth_token", &auth_token.as_ref().map(|_| "<token>"))
136 .field("auth_token_from_env", auth_token_from_env)
137 .field("custom_url", custom_url)
138 .field("request", request)
139 .finish()
140 }
141}
142
143impl ReleaseListBuilder {
144 pub fn repo_owner(&mut self, owner: impl Into<String>) -> &mut Self {
146 self.repo_owner = Some(owner.into());
147 self
148 }
149
150 pub fn repo_name(&mut self, name: impl Into<String>) -> &mut Self {
152 self.repo_name = Some(name.into());
153 self
154 }
155
156 pub fn filter_target(&mut self, target: impl Into<String>) -> &mut Self {
158 self.target = Some(target.into());
159 self
160 }
161
162 pub fn api_base_url(&mut self, url: impl Into<String>) -> &mut Self {
172 self.custom_url = Some(url.into());
173 self
174 }
175
176 pub fn auth_token(&mut self, auth_token: impl Into<String>) -> &mut Self {
190 crate::backends::common::set_explicit_auth_token(
191 &mut self.auth_token,
192 &mut self.auth_token_from_env,
193 auth_token,
194 );
195 self
196 }
197
198 impl_auth_token_from_env!(
199 token: auth_token,
200 env_sourced: auth_token_from_env,
201 vars: ["GH_TOKEN", "GITHUB_TOKEN"],
205 rationale: "The main reason to reach for this is GitHub's unauthenticated budget of 60 \
206 requests per hour, which is counted **per source IP**: behind a NAT'd corporate \
207 network it is shared with everyone else on that IP and can be exhausted by \
208 other people entirely, surfacing as \
209 [`RateLimited`](crate::errors::Error::RateLimited). A token moves the \
210 count to its own 5000/hour budget; see the crate-level \"GitHub rate limits\" \
211 section.\n\nThe variables match the `gh` CLI's precedence. Note that `gh` also \
212 reads `GH_ENTERPRISE_TOKEN` / `GITHUB_ENTERPRISE_TOKEN` for a GitHub Enterprise \
213 host; this crate does not, so an enterprise `api_base_url` still needs one of \
214 the variables above (or an explicit `auth_token(..)`).",
215 );
216
217 request_config_setters!(request);
218
219 pub fn build(&self) -> Result<ReleaseList> {
221 let mut request = self.request.clone();
224 request.auth_scheme = crate::backends::common::AuthScheme::Token;
225 request.auth_token = self.auth_token.clone();
226 request.auth_base_host = crate::backends::common::host_of(
227 self.custom_url
228 .as_deref()
229 .unwrap_or("https://api.github.com"),
230 );
231 request.build_client();
232 request.check()?;
233 crate::backends::common::env_token_host_decision(
239 self.auth_token_from_env,
240 request.auth_base_host.as_deref(),
241 &request.auth_hosts,
242 Some(CANONICAL_AUTH_HOST),
243 );
244 Ok(ReleaseList {
245 repo_owner: if let Some(ref owner) = self.repo_owner {
246 owner.to_owned()
247 } else {
248 return Err(Error::MissingField {
249 field: "repo_owner",
250 });
251 },
252 repo_name: if let Some(ref name) = self.repo_name {
253 name.to_owned()
254 } else {
255 return Err(Error::MissingField { field: "repo_name" });
256 },
257 target: self.target.clone(),
258 custom_url: self.custom_url.clone(),
259 request,
260 })
261 }
262}
263
264#[derive(Clone, Debug)]
267pub struct ReleaseList {
268 repo_owner: String,
269 repo_name: String,
270 target: Option<String>,
271 custom_url: Option<String>,
272 request: RequestConfig,
273}
274impl ReleaseList {
275 pub fn configure() -> ReleaseListBuilder {
277 ReleaseListBuilder {
278 repo_owner: None,
279 repo_name: None,
280 target: None,
281 auth_token: None,
282 auth_token_from_env: false,
283 custom_url: None,
284 request: RequestConfig::default(),
285 }
286 }
287
288 pub fn fetch(&self) -> Result<Releases> {
295 let api_url = format!(
296 "{}/repos/{}/{}/releases",
297 self.custom_url
298 .as_deref()
299 .unwrap_or("https://api.github.com"),
300 urlencoding::encode(&self.repo_owner),
301 urlencoding::encode(&self.repo_name)
302 );
303 let releases = run_paginated(releases_plan(&api_url, None, None)?, &self.request)?;
305 let releases = match self.target {
306 None => releases,
307 Some(ref target) => releases
308 .into_iter()
309 .filter(|r| r.has_target_asset(target))
310 .collect::<Vec<_>>(),
311 };
312 Ok(Releases::from_listing(releases))
313 }
314
315 #[cfg(feature = "async")]
317 pub async fn fetch_async(&self) -> Result<Releases> {
318 let api_url = format!(
319 "{}/repos/{}/{}/releases",
320 self.custom_url
321 .as_deref()
322 .unwrap_or("https://api.github.com"),
323 urlencoding::encode(&self.repo_owner),
324 urlencoding::encode(&self.repo_name)
325 );
326 let releases = crate::backends::run_paginated_async(
328 releases_plan(&api_url, None, None)?,
329 &self.request,
330 )
331 .await?;
332 let releases = match self.target {
333 None => releases,
334 Some(ref target) => releases
335 .into_iter()
336 .filter(|r| r.has_target_asset(target))
337 .collect::<Vec<_>>(),
338 };
339 Ok(Releases::from_listing(releases))
340 }
341}
342
343#[derive(Clone, Debug, Default)]
348#[must_use]
349pub struct UpdateBuilder {
350 repo_owner: Option<String>,
351 repo_name: Option<String>,
352 custom_url: Option<String>,
353 common: CommonBuilderConfig,
354}
355
356impl UpdateBuilder {
357 pub fn new() -> Self {
359 Default::default()
360 }
361
362 pub fn repo_owner(&mut self, owner: impl Into<String>) -> &mut Self {
364 self.repo_owner = Some(owner.into());
365 self
366 }
367
368 pub fn repo_name(&mut self, name: impl Into<String>) -> &mut Self {
370 self.repo_name = Some(name.into());
371 self
372 }
373
374 pub fn api_base_url(&mut self, url: impl Into<String>) -> &mut Self {
378 self.custom_url = Some(url.into());
379 self
380 }
381
382 pub fn tag_prefix(&mut self, prefix: impl Into<String>) -> &mut Self {
387 self.common.tag_prefix = Some(prefix.into());
388 self
389 }
390
391 impl_common_builder_setters!(
392 auth_env: ["GH_TOKEN", "GITHUB_TOKEN"],
396 rationale: "The main reason to reach for this is GitHub's unauthenticated budget of 60 \
397 requests per hour, which is counted **per source IP**: behind a NAT'd corporate \
398 network it is shared with everyone else on that IP and can be exhausted by \
399 other people entirely, surfacing as \
400 [`RateLimited`](crate::errors::Error::RateLimited). A token moves the \
401 count to its own 5000/hour budget; see the crate-level \"GitHub rate limits\" \
402 section.\n\nThe variables match the `gh` CLI's precedence. Note that `gh` also \
403 reads `GH_ENTERPRISE_TOKEN` / `GITHUB_ENTERPRISE_TOKEN` for a GitHub Enterprise \
404 host; this crate does not, so an enterprise `api_base_url` still needs one of \
405 the variables above (or an explicit `auth_token(..)`).",
406 );
407
408 fn build_update(&self) -> Result<Update> {
409 Ok(Update {
410 repo_owner: if let Some(ref owner) = self.repo_owner {
411 owner.to_owned()
412 } else {
413 return Err(Error::MissingField {
414 field: "repo_owner",
415 });
416 },
417 repo_name: if let Some(ref name) = self.repo_name {
418 name.to_owned()
419 } else {
420 return Err(Error::MissingField { field: "repo_name" });
421 },
422 custom_url: self.custom_url.clone(),
423 common: {
424 let mut resolved = self.common.build()?;
425 resolved.request.auth_scheme = crate::backends::common::AuthScheme::Token;
428 resolved.request.auth_base_host = crate::backends::common::host_of(
431 self.custom_url
432 .as_deref()
433 .unwrap_or("https://api.github.com"),
434 );
435 crate::backends::common::env_token_host_decision(
441 self.common.auth_token_from_env,
442 resolved.request.auth_base_host.as_deref(),
443 &resolved.request.auth_hosts,
444 Some(CANONICAL_AUTH_HOST),
445 );
446 resolved
447 },
448 })
449 }
450
451 pub fn build(&self) -> Result<Update> {
460 self.build_update()
461 }
462
463 #[cfg(feature = "async")]
469 pub fn build_async(&self) -> Result<AsyncUpdate> {
470 Ok(AsyncUpdate(self.build_update()?))
471 }
472}
473
474#[derive(Debug)]
476#[non_exhaustive]
477pub struct Update {
478 repo_owner: String,
479 repo_name: String,
480 custom_url: Option<String>,
481 common: CommonConfig,
482}
483impl Update {
484 pub fn configure() -> UpdateBuilder {
486 UpdateBuilder::new()
487 }
488
489 fn api_base(&self) -> &str {
492 self.custom_url
493 .as_deref()
494 .unwrap_or("https://api.github.com")
495 }
496}
497
498impl crate::update::sealed::Sealed for Update {}
499
500impl Update {
501 fn releases_url(&self) -> String {
503 format!(
504 "{}/repos/{}/{}/releases",
505 self.api_base(),
506 urlencoding::encode(&self.repo_owner),
507 urlencoding::encode(&self.repo_name)
508 )
509 }
510
511 fn latest_url(&self) -> String {
513 format!(
514 "{}/repos/{}/{}/releases/latest",
515 self.api_base(),
516 urlencoding::encode(&self.repo_owner),
517 urlencoding::encode(&self.repo_name)
518 )
519 }
520
521 fn tag_url(&self, ver: &str) -> String {
523 format!(
524 "{}/repos/{}/{}/releases/tags/{}",
525 self.api_base(),
526 urlencoding::encode(&self.repo_owner),
527 urlencoding::encode(&self.repo_name),
528 urlencoding::encode(ver)
529 )
530 }
531}
532
533impl ReleaseUpdate for Update {
534 fn get_latest_release(&self) -> Result<Releases> {
535 let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
536 let releases = run_paginated(
537 single_plan(self.latest_url(), self.common.tag_prefix.as_deref())?,
538 &self.common.request,
539 )?;
540 let release = releases
541 .into_iter()
542 .next()
543 .ok_or_else(|| Error::NoReleaseFound { target: None })?;
544 Ok(Releases::new(vec![release], current_version))
545 }
546
547 fn get_newer_releases(&self) -> Result<Releases> {
548 let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
549 let releases = run_paginated(
550 releases_plan(
551 &self.releases_url(),
552 Some(¤t_version),
553 self.common.tag_prefix.as_deref(),
554 )?,
555 &self.common.request,
556 )?;
557 Ok(Releases::new(releases, current_version))
558 }
559
560 fn get_release_version(&self, ver: &str) -> Result<Release> {
561 let releases = run_paginated(
562 single_plan(self.tag_url(ver), self.common.tag_prefix.as_deref())?,
563 &self.common.request,
564 )?;
565 releases
566 .into_iter()
567 .next()
568 .ok_or_else(|| Error::NoReleaseFound { target: None })
569 }
570}
571
572impl_sync_update_verbs!(Update);
573
574#[cfg(feature = "async")]
581#[derive(Debug)]
582pub struct AsyncUpdate(Update);
583
584#[cfg(feature = "async")]
585impl_async_update_verbs!(AsyncUpdate);
586
587impl_update_config_accessors!(Update, {
588 fn api_headers(&self, _auth_token: Option<&str>) -> Result<HeaderMap> {
589 api_headers()
590 }
591});
592
593fn releases_plan(
602 base_url: &str,
603 stop_at: Option<&str>,
604 tag_prefix: Option<&str>,
605) -> Result<PageRequest<Release>> {
606 let headers = api_headers()?;
607 let stop_at = stop_at.map(str::to_owned);
608 Ok(release_array_page(
609 first_page_url(base_url),
610 headers,
611 stop_at,
612 tag_prefix.map(str::to_owned),
613 ))
614}
615
616fn release_array_page(
618 url: String,
619 headers: HeaderMap,
620 stop_at: Option<String>,
621 tag_prefix: Option<String>,
622) -> PageRequest<Release> {
623 PageRequest {
624 url,
625 headers,
626 parse: Box::new(move |body, resp_headers| {
627 let dtos: Vec<ReleaseDto> =
630 serde_json::from_slice(body).map_err(|e| Error::InvalidResponse {
631 source: Box::new(e),
632 })?;
633 let mut items = Vec::new();
634 for dto in dtos {
635 let release = match dto.into_release(tag_prefix.as_deref()) {
636 Ok(release) => release,
637 Err(e @ Error::SemVer(_)) => {
641 log::debug!("self_update: skipping listed release: {e}");
642 continue;
643 }
644 Err(e) => return Err(e),
645 };
646 if let Some(ref current) = stop_at
650 && !bump_is_greater(current, release.version()).unwrap_or(false)
651 {
652 continue;
653 }
654 items.push(release);
655 }
656 let next = next_link(resp_headers)
657 .map(|next_url| -> Result<PageRequest<Release>> {
658 Ok(release_array_page(
659 next_url,
660 api_headers()?,
661 stop_at.clone(),
662 tag_prefix.clone(),
663 ))
664 })
665 .transpose()?;
666 Ok(Page {
667 items,
668 next,
669 stop: false,
670 })
671 }),
672 }
673}
674
675fn single_plan(url: String, tag_prefix: Option<&str>) -> Result<PageRequest<Release>> {
678 let headers = api_headers()?;
679 let tag_prefix = tag_prefix.map(str::to_owned);
680 Ok(PageRequest {
681 url,
682 headers,
683 parse: Box::new(move |body, _resp_headers| {
684 let dto: ReleaseDto =
688 serde_json::from_slice(body).map_err(crate::errors::Error::invalid_response)?;
689 Ok(Page::last(vec![dto.into_release(tag_prefix.as_deref())?]))
690 }),
691 })
692}
693
694#[cfg(feature = "async")]
695impl crate::update::AsyncReleaseUpdate for Update {
696 async fn get_latest_release_async(&self) -> Result<Releases> {
697 use crate::backends::run_paginated_async;
698 let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
699 let releases = run_paginated_async(
700 single_plan(self.latest_url(), self.common.tag_prefix.as_deref())?,
701 &self.common.request,
702 )
703 .await?;
704 let release = releases
705 .into_iter()
706 .next()
707 .ok_or_else(|| Error::NoReleaseFound { target: None })?;
708 Ok(Releases::new(vec![release], current_version))
709 }
710
711 async fn get_newer_releases_async(&self) -> Result<Releases> {
712 use crate::backends::run_paginated_async;
713 let current_version = crate::update::UpdateConfig::current_version(self).to_owned();
714 let releases = run_paginated_async(
715 releases_plan(
716 &self.releases_url(),
717 Some(¤t_version),
718 self.common.tag_prefix.as_deref(),
719 )?,
720 &self.common.request,
721 )
722 .await?;
723 Ok(Releases::new(releases, current_version))
724 }
725
726 async fn get_release_version_async(&self, ver: &str) -> Result<Release> {
727 use crate::backends::run_paginated_async;
728 let releases = run_paginated_async(
729 single_plan(self.tag_url(ver), self.common.tag_prefix.as_deref())?,
730 &self.common.request,
731 )
732 .await?;
733 releases
734 .into_iter()
735 .next()
736 .ok_or_else(|| Error::NoReleaseFound { target: None })
737 }
738}
739
740fn api_headers() -> Result<header::HeaderMap> {
746 let mut headers = header::HeaderMap::new();
747 headers.insert(
748 header::USER_AGENT,
749 crate::DEFAULT_USER_AGENT
750 .parse()
751 .expect("github invalid user-agent"),
752 );
753 Ok(headers)
754}
755
756#[cfg(test)]
757mod tests {
758 use std::io::{Read, Write};
759 use std::net::TcpListener;
760
761 #[allow(unused_imports)]
765 use crate::update::UpdateInternals;
766
767 use crate::update::UpdateConfig;
770
771 #[test]
782 fn auth_token_from_env_is_available_on_both_builders() {
783 super::Update::configure()
784 .repo_owner("o")
785 .repo_name("r")
786 .bin_name("app")
787 .current_version("0.1.0")
788 .auth_token_from_env()
789 .build()
790 .expect("an env-sourced token must leave the update builder buildable");
791 super::ReleaseList::configure()
792 .repo_owner("o")
793 .repo_name("r")
794 .auth_token_from_env()
795 .build()
796 .expect("an env-sourced token must leave the release-list builder buildable");
797 }
798
799 #[test]
806 fn auth_token_env_vars_are_gh_token_then_github_token() {
807 assert_eq!(
808 super::UpdateBuilder::AUTH_TOKEN_ENV_VARS,
809 ["GH_TOKEN", "GITHUB_TOKEN"]
810 );
811 assert_eq!(
812 super::ReleaseListBuilder::AUTH_TOKEN_ENV_VARS,
813 ["GH_TOKEN", "GITHUB_TOKEN"]
814 );
815 }
816
817 #[test]
821 fn an_explicit_auth_token_wins_over_the_env_lookup_in_either_order() {
822 use crate::http_client::header::{AUTHORIZATION, HeaderMap};
823 let env_then_explicit = super::Update::configure()
824 .repo_owner("o")
825 .repo_name("r")
826 .bin_name("app")
827 .current_version("0.1.0")
828 .auth_token_from_env()
829 .auth_token("explicit")
830 .build()
831 .unwrap();
832 let explicit_then_env = super::Update::configure()
833 .repo_owner("o")
834 .repo_name("r")
835 .bin_name("app")
836 .current_version("0.1.0")
837 .auth_token("explicit")
838 .auth_token_from_env()
839 .build()
840 .unwrap();
841 for upd in [env_then_explicit, explicit_then_env] {
842 let mut headers = HeaderMap::new();
843 upd.request_config()
844 .apply_auth("https://api.github.com/repos/o/r/releases", &mut headers)
845 .unwrap();
846 assert_eq!(
847 headers.get(AUTHORIZATION).unwrap().to_str().unwrap(),
848 "token explicit",
849 "an explicit auth_token(..) must win in either call order"
850 );
851 }
852 }
853
854 #[test]
856 fn release_list_explicit_auth_token_wins_over_the_env_lookup_in_either_order() {
857 let env_then_explicit = super::ReleaseList::configure()
858 .repo_owner("o")
859 .repo_name("r")
860 .auth_token_from_env()
861 .auth_token("explicit")
862 .build()
863 .unwrap();
864 let explicit_then_env = super::ReleaseList::configure()
865 .repo_owner("o")
866 .repo_name("r")
867 .auth_token("explicit")
868 .auth_token_from_env()
869 .build()
870 .unwrap();
871 for list in [env_then_explicit, explicit_then_env] {
872 assert_eq!(list.request.auth_token.as_deref(), Some("explicit"));
873 }
874 }
875
876 #[test]
880 fn has_auth_token_reports_an_explicitly_set_token() {
881 let mut upd = super::Update::configure();
882 assert!(
883 !upd.has_auth_token(),
884 "a fresh builder has no token: the update runs anonymously"
885 );
886 upd.auth_token("explicit");
887 assert!(upd.has_auth_token());
888
889 let mut list = super::ReleaseList::configure();
890 assert!(!list.has_auth_token());
891 list.auth_token("explicit");
892 assert!(list.has_auth_token());
893 }
894
895 #[test]
900 fn has_auth_token_treats_a_blank_explicit_token_as_unset() {
901 let mut upd = super::Update::configure();
902 upd.auth_token("");
903 assert!(
904 !upd.has_auth_token(),
905 "an empty token must not count as configured"
906 );
907 upd.auth_token(" ");
908 assert!(
909 !upd.has_auth_token(),
910 "an all-whitespace token must not count as configured"
911 );
912
913 let mut list = super::ReleaseList::configure();
914 list.auth_token("");
915 assert!(!list.has_auth_token());
916 list.auth_token(" ");
917 assert!(!list.has_auth_token());
918 }
919
920 #[test]
925 fn release_list_still_sends_an_env_sourced_token_off_the_canonical_host() {
926 let mut list = super::ReleaseList::configure();
927 list.repo_owner("o")
928 .repo_name("r")
929 .api_base_url("https://github.mycorp.com/api/v3");
930 list.auth_token = Some("ambient".to_string());
931 list.auth_token_from_env = true;
932 let built = list
933 .build()
934 .expect("an unacknowledged host must not fail build()");
935 assert_eq!(
936 built.request.auth_token.as_deref(),
937 Some("ambient"),
938 "github must still send an env-sourced token off its canonical host"
939 );
940 }
941
942 #[test]
946 fn builder_debug_redacts_the_auth_token() {
947 let mut upd = super::Update::configure();
948 upd.repo_owner("owner-o")
949 .repo_name("r")
950 .bin_name("app")
951 .current_version("0.1.0")
952 .auth_token("ghp_supersecret");
953 let rendered = format!("{upd:?}");
954 assert!(
955 !rendered.contains("ghp_supersecret"),
956 "the UpdateBuilder must not print the token, got: {rendered}"
957 );
958 assert!(rendered.contains("<token>"), "got: {rendered}");
959 assert!(
960 rendered.contains("owner-o"),
961 "other fields must survive the hand-written Debug, got: {rendered}"
962 );
963
964 let mut list = super::ReleaseList::configure();
965 list.repo_owner("owner-o")
966 .repo_name("r")
967 .auth_token("ghp_supersecret");
968 let rendered = format!("{list:?}");
969 assert!(
970 !rendered.contains("ghp_supersecret"),
971 "the ReleaseListBuilder must not print the token, got: {rendered}"
972 );
973 assert!(rendered.contains("<token>"), "got: {rendered}");
974 assert!(rendered.contains("owner-o"), "got: {rendered}");
975 }
976
977 #[test]
982 fn release_list_builder_debug_renders_every_field() {
983 let rendered = format!("{:?}", super::ReleaseList::configure());
984 for field in [
985 "repo_owner",
986 "repo_name",
987 "target",
988 "auth_token",
989 "auth_token_from_env",
990 "custom_url",
991 "request",
992 ] {
993 assert!(
994 rendered.contains(&format!("{field}:")),
995 "the hand-written Debug dropped `{field}`, got: {rendered}"
996 );
997 }
998 }
999
1000 #[test]
1006 fn built_types_debug_redacts_the_auth_token() {
1007 let upd = super::Update::configure()
1008 .repo_owner("owner-o")
1009 .repo_name("r")
1010 .bin_name("app")
1011 .current_version("0.1.0")
1012 .auth_token("ghp_supersecret")
1013 .build()
1014 .unwrap();
1015 let rendered = format!("{upd:?}");
1016 assert!(
1017 !rendered.contains("ghp_supersecret"),
1018 "the built Update must not print the token, got: {rendered}"
1019 );
1020 assert!(
1021 rendered.contains("<token>"),
1022 "the token must still render as the redaction marker, got: {rendered}"
1023 );
1024 assert!(
1025 rendered.contains("owner-o"),
1026 "non-secret fields must survive, got: {rendered}"
1027 );
1028
1029 let list = super::ReleaseList::configure()
1030 .repo_owner("owner-o")
1031 .repo_name("r")
1032 .auth_token("ghp_supersecret")
1033 .build()
1034 .unwrap();
1035 let rendered = format!("{list:?}");
1036 assert!(
1037 !rendered.contains("ghp_supersecret"),
1038 "the built ReleaseList must not print the token, got: {rendered}"
1039 );
1040 assert!(rendered.contains("<token>"), "got: {rendered}");
1041 assert!(rendered.contains("owner-o"), "got: {rendered}");
1042
1043 #[cfg(feature = "async")]
1046 {
1047 let upd = super::Update::configure()
1048 .repo_owner("owner-o")
1049 .repo_name("r")
1050 .bin_name("app")
1051 .current_version("0.1.0")
1052 .auth_token("ghp_supersecret")
1053 .build_async()
1054 .unwrap();
1055 let rendered = format!("{upd:?}");
1056 assert!(
1057 !rendered.contains("ghp_supersecret"),
1058 "the built AsyncUpdate must not print the token, got: {rendered}"
1059 );
1060 assert!(rendered.contains("<token>"), "got: {rendered}");
1061 }
1062 }
1063
1064 #[test]
1068 fn single_plan_parse_failure_is_invalid_response() {
1069 let req =
1070 super::single_plan("https://example.test/releases/latest".to_string(), None).unwrap();
1071 let res = (req.parse)(b"not-json", &crate::http_client::HeaderMap::new());
1072 assert!(
1073 matches!(res, Err(crate::errors::Error::InvalidResponse { .. })),
1074 "a malformed single-release body must map to InvalidResponse"
1075 );
1076 }
1077
1078 #[test]
1086 fn listing_with_tag_prefix_parses_prefixed_tags_and_skips_others() {
1087 let req = super::release_array_page(
1088 "https://example.test/releases".to_string(),
1089 crate::http_client::HeaderMap::new(),
1090 None,
1091 Some("myapp-".to_string()),
1092 );
1093 let body = releases_array_json(&["myapp-1.2.3", "otherapp-2.0.0", "myapp-v1.3.0", "1.0.0"]);
1094 let page = (req.parse)(body.as_bytes(), &crate::http_client::HeaderMap::new()).unwrap();
1095 let versions: Vec<&str> = page.items.iter().map(|r| r.version()).collect();
1096 assert_eq!(
1097 versions,
1098 vec!["1.2.3", "1.3.0"],
1099 "only `myapp-`-prefixed tags are parsed (with an optional inner `v`); the rest are skipped"
1100 );
1101 }
1102
1103 #[test]
1104 fn listing_skips_non_semver_tags() {
1105 let req = super::release_array_page(
1106 "https://example.test/releases".to_string(),
1107 crate::http_client::HeaderMap::new(),
1108 None,
1109 None,
1110 );
1111 let body = releases_array_json(&[
1112 "nightly",
1113 "v2.0.0-rc.1+build",
1114 "v1.2.3",
1115 "2024-06-01",
1116 "V1.1.0",
1117 "v1.0.0",
1118 ]);
1119 let page = (req.parse)(body.as_bytes(), &crate::http_client::HeaderMap::new()).unwrap();
1120 let versions: Vec<&str> = page.items.iter().map(|r| r.version()).collect();
1121 assert_eq!(
1122 versions,
1123 vec!["2.0.0-rc.1+build", "1.2.3", "1.0.0"],
1124 "non-semver (incl. capital-V) tags are skipped; semver incl. pre-release survives"
1125 );
1126 }
1127
1128 #[test]
1131 fn fetch_continues_past_an_all_non_semver_page() {
1132 let (base, captured) = stub_capturing(|base| {
1133 vec![
1134 Resp {
1135 status: "200 OK",
1136 link: Some(format!("{base}/repos/o/r/releases?page=2")),
1137 body: releases_array_json(&["nightly", "latest"]),
1138 },
1139 Resp {
1140 status: "200 OK",
1141 link: None,
1142 body: releases_array_json(&["v3.0.0"]),
1143 },
1144 ]
1145 });
1146 let releases = fetch_all_releases(
1147 &format!("{base}/repos/o/r/releases"),
1148 &crate::backends::common::RequestConfig::default(),
1149 )
1150 .unwrap();
1151 let versions: Vec<&str> = releases.iter().map(|r| r.version()).collect();
1152 assert_eq!(versions, vec!["3.0.0"]);
1153 assert_eq!(
1154 captured.lock().unwrap().len(),
1155 2,
1156 "an all-skipped page 1 must not stop pagination; page 2 must be requested"
1157 );
1158 }
1159
1160 #[cfg(feature = "async")]
1163 #[tokio::test]
1164 async fn fetch_async_skips_non_semver_tags() {
1165 let base = stub(|_| {
1166 vec![Resp {
1167 status: "200 OK",
1168 link: None,
1169 body: releases_array_json(&["nightly", "v1.2.3"]),
1170 }]
1171 });
1172 let releases = fetch_all_releases_async(
1173 &format!("{base}/repos/o/r/releases"),
1174 &crate::backends::common::RequestConfig::default(),
1175 )
1176 .await
1177 .unwrap();
1178 let versions: Vec<&str> = releases.iter().map(|r| r.version()).collect();
1179 assert_eq!(versions, vec!["1.2.3"]);
1180 }
1181
1182 #[test]
1185 fn filtered_listing_skips_non_semver_tags() {
1186 let req = super::release_array_page(
1187 "https://example.test/releases".to_string(),
1188 crate::http_client::HeaderMap::new(),
1189 Some("1.0.0".to_string()),
1190 None,
1191 );
1192 let body = releases_array_json(&["nightly", "v1.2.3", "v0.9.0"]);
1193 let page = (req.parse)(body.as_bytes(), &crate::http_client::HeaderMap::new()).unwrap();
1194 let versions: Vec<&str> = page.items.iter().map(|r| r.version()).collect();
1195 assert_eq!(versions, vec!["1.2.3"]);
1196 }
1197
1198 #[test]
1201 fn single_plan_non_semver_tag_errors_naming_the_tag() {
1202 let req = super::single_plan(
1203 "https://example.test/releases/tags/nightly".to_string(),
1204 None,
1205 )
1206 .unwrap();
1207 let res = (req.parse)(
1208 release_obj_json("nightly").as_bytes(),
1209 &crate::http_client::HeaderMap::new(),
1210 );
1211 match res {
1212 Err(crate::errors::Error::SemVer(e)) => {
1213 assert!(
1214 e.to_string().contains("nightly"),
1215 "the error must name the offending tag, got: {e}"
1216 );
1217 }
1218 Err(other) => panic!("expected Error::SemVer, got {other:?}"),
1219 Ok(_) => panic!("a non-semver pinned tag must error"),
1220 }
1221 }
1222
1223 fn fetch_all_releases(
1226 base_url: &str,
1227 req: &crate::backends::common::RequestConfig,
1228 ) -> crate::errors::Result<Vec<super::Release>> {
1229 crate::backends::run_paginated(super::releases_plan(base_url, None, None)?, req)
1230 }
1231
1232 #[cfg(feature = "async")]
1234 async fn fetch_all_releases_async(
1235 base_url: &str,
1236 req: &crate::backends::common::RequestConfig,
1237 ) -> crate::errors::Result<Vec<super::Release>> {
1238 crate::backends::run_paginated_async(super::releases_plan(base_url, None, None)?, req).await
1239 }
1240
1241 struct Resp {
1242 status: &'static str,
1243 link: Option<String>,
1244 body: String,
1245 }
1246
1247 fn stub(make: impl FnOnce(&str) -> Vec<Resp>) -> String {
1251 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1252 let base = format!("http://{}", listener.local_addr().unwrap());
1253 let responses = make(&base);
1254 std::thread::spawn(move || {
1255 for r in responses {
1256 let (mut stream, _) = match listener.accept() {
1257 Ok(c) => c,
1258 Err(_) => return,
1259 };
1260 let mut buf = [0u8; 4096];
1261 let _ = stream.read(&mut buf); let mut out = format!(
1263 "HTTP/1.1 {}\r\nContent-Type: application/json\r\n",
1264 r.status
1265 );
1266 if let Some(link) = r.link {
1267 out.push_str(&format!("Link: <{link}>; rel=\"next\"\r\n"));
1268 }
1269 out.push_str(&format!(
1270 "Content-Length: {}\r\nConnection: close\r\n\r\n{}",
1271 r.body.len(),
1272 r.body
1273 ));
1274 let _ = stream.write_all(out.as_bytes());
1275 let _ = stream.flush();
1276 }
1277 });
1278 base
1279 }
1280
1281 fn release_json(tag: &str) -> String {
1282 format!(
1283 r#"[{{"tag_name":"{tag}","created_at":"2020-01-01T00:00:00Z","name":"{tag}","assets":[]}}]"#
1284 )
1285 }
1286
1287 fn release_obj_json(tag: &str) -> String {
1288 format!(
1289 r#"{{"tag_name":"{tag}","created_at":"2020-01-01T00:00:00Z","name":"{tag}","assets":[]}}"#
1290 )
1291 }
1292
1293 fn releases_array_json(tags: &[&str]) -> String {
1295 let objs = tags
1296 .iter()
1297 .map(|tag| {
1298 format!(
1299 r#"{{"tag_name":"{tag}","created_at":"2020-01-01T00:00:00Z","name":"{tag}","assets":[]}}"#
1300 )
1301 })
1302 .collect::<Vec<_>>()
1303 .join(",");
1304 format!("[{objs}]")
1305 }
1306
1307 #[test]
1310 fn release_dto_populates_release_notes_url_from_html_url() {
1311 let with_url: super::ReleaseDto = serde_json::from_str(
1312 r#"{"tag_name":"v1.2.3","created_at":"2020-01-01T00:00:00Z","name":"v1.2.3",
1313 "html_url":"https://github.com/o/r/releases/tag/v1.2.3","assets":[]}"#,
1314 )
1315 .unwrap();
1316 let release = with_url.into_release(None).unwrap();
1317 assert_eq!(
1318 release.release_notes_url(),
1319 Some("https://github.com/o/r/releases/tag/v1.2.3")
1320 );
1321
1322 let without: super::ReleaseDto = serde_json::from_str(
1323 r#"{"tag_name":"v1.2.3","created_at":"2020-01-01T00:00:00Z","name":"v1.2.3","assets":[]}"#,
1324 )
1325 .unwrap();
1326 assert_eq!(
1327 without.into_release(None).unwrap().release_notes_url(),
1328 None
1329 );
1330 }
1331
1332 #[test]
1335 fn get_newer_releases_continues_past_non_newer_releases_and_fetches_page_two() {
1336 let (base, captured) = stub_capturing(|base| {
1341 vec![
1342 Resp {
1343 status: "200 OK",
1344 link: Some(format!("{base}/repos/o/r/releases?page=2")),
1345 body: releases_array_json(&["v2.0.0", "v1.5.0", "v1.0.0", "v0.9.0"]),
1346 },
1347 Resp {
1348 status: "200 OK",
1349 link: None,
1350 body: releases_array_json(&["v3.0.0"]),
1351 },
1352 ]
1353 });
1354 let upd = github_update_sync(&base, "1.0.0");
1355 let releases = upd.get_newer_releases().unwrap();
1356 let versions: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
1357 assert_eq!(versions, vec!["2.0.0", "1.5.0", "3.0.0"]);
1360 assert_eq!(
1361 captured.lock().unwrap().len(),
1362 2,
1363 "non-newer releases must not halt pagination; both pages must be requested"
1364 );
1365 }
1366
1367 #[test]
1368 fn early_stop_selects_same_release_as_a_full_walk() {
1369 let early_first_page = releases_array_json(&["v2.0.0", "v1.5.0", "v1.0.0", "v0.9.0"]);
1374 let (base, _captured) = stub_capturing(move |base| {
1375 vec![
1376 Resp {
1377 status: "200 OK",
1378 link: Some(format!("{base}/repos/o/r/releases?page=2")),
1379 body: early_first_page,
1380 },
1381 Resp {
1382 status: "200 OK",
1383 link: None,
1384 body: releases_array_json(&["v0.5.0"]),
1385 },
1386 ]
1387 });
1388 let upd = github_update_sync(&base, "1.0.0");
1389 let early = upd.get_newer_releases().unwrap().into_vec();
1390 let early_choice =
1391 crate::update::testing::choose_latest_release_for_test(early, "1.0.0").unwrap();
1392
1393 let full = vec![
1397 crate::update::Release::builder()
1398 .version("2.0.0")
1399 .build()
1400 .unwrap(),
1401 crate::update::Release::builder()
1402 .version("1.5.0")
1403 .build()
1404 .unwrap(),
1405 crate::update::Release::builder()
1406 .version("1.0.0")
1407 .build()
1408 .unwrap(),
1409 crate::update::Release::builder()
1410 .version("0.9.0")
1411 .build()
1412 .unwrap(),
1413 crate::update::Release::builder()
1414 .version("0.5.0")
1415 .build()
1416 .unwrap(),
1417 ];
1418 let full_choice =
1419 crate::update::testing::choose_latest_release_for_test(full, "1.0.0").unwrap();
1420 assert_eq!(
1421 early_choice.map(|r| r.version().to_string()),
1422 full_choice.map(|r| r.version().to_string()),
1423 "early-stop must select the same release as a full walk"
1424 );
1425 }
1426
1427 #[test]
1428 fn release_list_fetch_walks_all_pages_unfiltered() {
1429 let (base, captured) = stub_capturing(|base| {
1433 vec![
1434 Resp {
1435 status: "200 OK",
1436 link: Some(format!("{base}/repos/o/r/releases?page=2")),
1437 body: releases_array_json(&["v2.0.0", "v0.5.0"]),
1438 },
1439 Resp {
1440 status: "200 OK",
1441 link: None,
1442 body: releases_array_json(&["v0.1.0"]),
1443 },
1444 ]
1445 });
1446 let releases = super::ReleaseList::configure()
1447 .api_base_url(&base)
1448 .repo_owner("o")
1449 .repo_name("r")
1450 .build()
1451 .unwrap()
1452 .fetch()
1453 .unwrap();
1454 let releases = releases.into_vec();
1457 let versions: Vec<&str> = releases.iter().map(|r| r.version()).collect();
1458 assert_eq!(
1459 versions,
1460 vec!["2.0.0", "0.5.0", "0.1.0"],
1461 "the unfiltered ReleaseList must accumulate ALL pages, older releases included"
1462 );
1463 assert_eq!(
1464 captured.lock().unwrap().len(),
1465 2,
1466 "both pages must be requested for the unfiltered listing"
1467 );
1468 }
1469
1470 #[test]
1473 fn release_list_fetch_returns_releases_and_into_vec_recovers_them() {
1474 let base = stub(|_| {
1478 vec![Resp {
1479 status: "200 OK",
1480 link: None,
1481 body: releases_array_json(&["v2.0.0", "v1.0.0"]),
1482 }]
1483 });
1484 let releases = super::ReleaseList::configure()
1485 .api_base_url(&base)
1486 .repo_owner("o")
1487 .repo_name("r")
1488 .build()
1489 .unwrap()
1490 .fetch()
1491 .unwrap();
1492 assert_eq!(
1493 releases.current_version(),
1494 None,
1495 "a bare listing carries no current version"
1496 );
1497 assert!(
1498 releases.is_update_available().is_err(),
1499 "a listing with no current version cannot answer is_update_available()"
1500 );
1501 let recovered = releases.into_vec();
1502 let versions: Vec<&str> = recovered.iter().map(|r| r.version()).collect();
1503 assert_eq!(versions, vec!["2.0.0", "1.0.0"]);
1504 }
1505
1506 #[cfg(feature = "async")]
1507 #[tokio::test]
1508 async fn release_list_fetch_async_returns_releases_and_into_vec_recovers_them() {
1509 let base = stub(|_| {
1514 vec![Resp {
1515 status: "200 OK",
1516 link: None,
1517 body: releases_array_json(&["v2.0.0", "v1.0.0"]),
1518 }]
1519 });
1520 let releases = super::ReleaseList::configure()
1521 .api_base_url(&base)
1522 .repo_owner("o")
1523 .repo_name("r")
1524 .build()
1525 .unwrap()
1526 .fetch_async()
1527 .await
1528 .unwrap();
1529 assert_eq!(
1530 releases.current_version(),
1531 None,
1532 "a bare listing carries no current version"
1533 );
1534 assert!(
1535 releases.is_update_available().is_err(),
1536 "a listing with no current version cannot answer is_update_available()"
1537 );
1538 let recovered = releases.into_vec();
1539 let versions: Vec<&str> = recovered.iter().map(|r| r.version()).collect();
1540 assert_eq!(versions, vec!["2.0.0", "1.0.0"]);
1541 }
1542
1543 #[test]
1546 fn github_dto_parses_sample_payload_through_getters() {
1547 let body = r#"{
1552 "tag_name": "v4.5.6",
1553 "name": "Release 4.5.6",
1554 "created_at": "2024-01-02T03:04:05Z",
1555 "body": "the release notes",
1556 "assets": [
1557 { "name": "app-x86_64-unknown-linux-gnu.tar.gz", "url": "https://api/asset/1",
1558 "digest": "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" },
1559 { "name": "app-aarch64-apple-darwin.tar.gz", "url": "https://api/asset/2" }
1560 ]
1561 }"#;
1562 let base = stub(move |_| {
1563 vec![Resp {
1564 status: "200 OK",
1565 link: None,
1566 body: body.to_string(),
1567 }]
1568 });
1569 let upd = github_update_sync(&base, "1.0.0");
1572 let releases = upd.get_latest_release().unwrap();
1573 let rel = releases.latest().expect("one-element Releases");
1574 assert_eq!(rel.version(), "4.5.6", "leading v stripped");
1575 assert_eq!(rel.name(), "Release 4.5.6");
1576 assert_eq!(rel.date(), "2024-01-02T03:04:05Z");
1577 assert_eq!(rel.body(), Some("the release notes"));
1578 assert_eq!(rel.assets().len(), 2);
1579 assert_eq!(
1580 rel.assets()[0].name(),
1581 "app-x86_64-unknown-linux-gnu.tar.gz"
1582 );
1583 assert_eq!(rel.assets()[0].download_url(), "https://api/asset/1");
1584 assert_eq!(
1585 rel.assets()[0].digest(),
1586 Some("sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"),
1587 "the API's per-asset digest field is carried onto the asset"
1588 );
1589 assert_eq!(
1590 rel.assets()[1].digest(),
1591 None,
1592 "an asset without a digest field parses with digest None"
1593 );
1594 }
1595
1596 #[cfg(feature = "async")]
1599 #[tokio::test]
1600 async fn sync_and_async_get_newer_releases_agree_on_identical_responses() {
1601 let body = releases_array_json(&["v2.0.0", "v1.5.0", "v1.0.0", "v0.9.0"]);
1605
1606 let sync_body = body.clone();
1607 let sync_versions: Vec<String> = tokio::task::spawn_blocking(move || {
1610 let sync_base = stub(move |_| {
1611 vec![Resp {
1612 status: "200 OK",
1613 link: None,
1614 body: sync_body,
1615 }]
1616 });
1617 github_update_sync(&sync_base, "1.0.0")
1618 .get_newer_releases()
1619 .unwrap()
1620 .all()
1621 .iter()
1622 .map(|r| r.version().to_string())
1623 .collect()
1624 })
1625 .await
1626 .unwrap();
1627
1628 let async_base = stub(move |_| {
1629 vec![Resp {
1630 status: "200 OK",
1631 link: None,
1632 body,
1633 }]
1634 });
1635 let upd = super::Update::configure()
1636 .repo_owner("o")
1637 .repo_name("r")
1638 .bin_name("app")
1639 .current_version("1.0.0")
1640 .api_base_url(&async_base)
1641 .build_async()
1642 .unwrap();
1643 let async_versions: Vec<String> = upd
1644 .get_newer_releases_async()
1645 .await
1646 .unwrap()
1647 .all()
1648 .iter()
1649 .map(|r| r.version().to_string())
1650 .collect();
1651
1652 assert_eq!(
1653 sync_versions, async_versions,
1654 "sync and async fetch must return the identical releases for the same response"
1655 );
1656 assert_eq!(
1657 sync_versions,
1658 vec!["2.0.0".to_string(), "1.5.0".to_string()],
1659 "and both apply the strictly-newer per-item filter"
1660 );
1661 }
1662
1663 #[cfg(feature = "async")]
1664 #[tokio::test]
1665 async fn fetch_all_releases_async_follows_pagination() {
1666 let base = stub(|base| {
1667 vec![
1668 Resp {
1669 status: "200 OK",
1670 link: Some(format!("{base}/releases?page=2")),
1671 body: release_json("v1.0.0"),
1672 },
1673 Resp {
1674 status: "200 OK",
1675 link: None,
1676 body: release_json("v0.9.0"),
1677 },
1678 ]
1679 });
1680 let releases = fetch_all_releases_async(
1681 &format!("{base}/releases"),
1682 &crate::backends::common::RequestConfig::default(),
1683 )
1684 .await
1685 .unwrap();
1686 assert_eq!(
1687 releases.len(),
1688 2,
1689 "both pages accumulated over async transport"
1690 );
1691 assert_eq!(releases[0].version(), "1.0.0");
1692 assert_eq!(releases[1].version(), "0.9.0");
1693 }
1694
1695 #[cfg(feature = "async")]
1696 #[tokio::test]
1697 async fn get_latest_release_async_parses_release() {
1698 let base = stub(|_| {
1699 vec![Resp {
1700 status: "200 OK",
1701 link: None,
1702 body: release_obj_json("v3.1.0"),
1703 }]
1704 });
1705 let upd = super::Update::configure()
1706 .repo_owner("o")
1707 .repo_name("r")
1708 .bin_name("app")
1709 .current_version("0.1.0")
1710 .api_base_url(&base)
1711 .build_async()
1712 .unwrap();
1713 let releases = upd.get_latest_release_async().await.unwrap();
1714 let rel = releases.latest().expect("one-element Releases");
1715 assert_eq!(rel.version(), "3.1.0");
1716 }
1717
1718 #[cfg(feature = "async")]
1719 #[tokio::test]
1720 async fn update_async_reports_up_to_date() {
1721 let base = stub(|_| {
1724 vec![Resp {
1725 status: "200 OK",
1726 link: None,
1727 body: release_json("v1.0.0"),
1728 }]
1729 });
1730 let upd = super::Update::configure()
1731 .repo_owner("o")
1732 .repo_name("r")
1733 .bin_name("app")
1734 .current_version("2.0.0")
1735 .api_base_url(&base)
1736 .no_confirm(true)
1737 .show_output(false)
1738 .build_async()
1739 .unwrap();
1740 let status = upd.update_extended_async().await.unwrap();
1741 assert!(status.is_up_to_date(), "an older release means up-to-date");
1742 }
1743
1744 #[cfg(feature = "async")]
1745 #[tokio::test]
1746 async fn is_update_available_async_reports_newest_newer_or_none() {
1747 let body = r#"[{"tag_name":"v2.0.0","created_at":"2020-01-01T00:00:00Z","name":"v2.0.0","assets":[]},{"tag_name":"v0.9.0","created_at":"2020-01-01T00:00:00Z","name":"v0.9.0","assets":[]}]"#;
1751 let mk = |cur: &'static str| {
1752 let base = stub(move |_| {
1753 vec![Resp {
1754 status: "200 OK",
1755 link: None,
1756 body: body.to_string(),
1757 }]
1758 });
1759 super::Update::configure()
1760 .repo_owner("o")
1761 .repo_name("r")
1762 .bin_name("app")
1763 .current_version(cur)
1764 .api_base_url(&base)
1765 .build_async()
1766 .unwrap()
1767 };
1768 let newer = mk("1.0.0").is_update_available_async().await.unwrap();
1769 assert_eq!(
1770 newer.map(|r| r.version().to_string()),
1771 Some("2.0.0".to_string()),
1772 "from 1.0.0 the 2.0.0 release is available"
1773 );
1774 assert!(
1775 mk("2.0.0")
1776 .is_update_available_async()
1777 .await
1778 .unwrap()
1779 .is_none(),
1780 "from 2.0.0 nothing newer => None"
1781 );
1782 }
1783
1784 #[test]
1785 fn get_newer_releases_sync_returns_releases_and_precheck() {
1786 let base = stub(|_| {
1790 vec![Resp {
1791 status: "200 OK",
1792 link: None,
1793 body: r#"[{"tag_name":"v2.0.0","created_at":"2020-01-01T00:00:00Z","name":"v2.0.0","assets":[]},{"tag_name":"v0.9.0","created_at":"2020-01-01T00:00:00Z","name":"v0.9.0","assets":[]}]"#.to_string(),
1794 }]
1795 });
1796 let upd = super::Update::configure()
1797 .repo_owner("o")
1798 .repo_name("r")
1799 .bin_name("app")
1800 .current_version("1.0.0")
1801 .api_base_url(&base)
1802 .build()
1803 .unwrap();
1804 let releases = upd.get_newer_releases().unwrap();
1805 let versions: Vec<&str> = releases.all().iter().map(|r| r.version()).collect();
1806 assert_eq!(versions, vec!["2.0.0"], "only strictly-newer releases kept");
1807 assert_eq!(releases.latest().unwrap().version(), "2.0.0");
1808 assert!(
1809 releases.is_update_available().unwrap(),
1810 "2.0.0 > 1.0.0 via the returned Releases"
1811 );
1812 }
1813
1814 fn github_update_sync(base: &str, current_version: &str) -> super::Update {
1815 super::Update::configure()
1816 .repo_owner("o")
1817 .repo_name("r")
1818 .bin_name("app")
1819 .current_version(current_version)
1820 .api_base_url(base)
1821 .build()
1822 .unwrap()
1823 }
1824
1825 #[test]
1826 fn get_latest_release_sync_wraps_single_object_into_one_element_releases() {
1827 let base = stub(|_| {
1832 vec![Resp {
1833 status: "200 OK",
1834 link: None,
1835 body: release_obj_json("v3.1.0"),
1836 }]
1837 });
1838 let upd = github_update_sync(&base, "1.0.0");
1839 let releases = upd.get_latest_release().unwrap();
1840 assert_eq!(
1841 releases.all().len(),
1842 1,
1843 "get_latest_release yields a one-element Releases"
1844 );
1845 assert_eq!(releases.latest().unwrap().version(), "3.1.0");
1846 assert!(
1847 releases.is_update_available().unwrap(),
1848 "3.1.0 > 1.0.0 via the one-element Releases pre-check"
1849 );
1850 }
1851
1852 #[test]
1853 fn get_latest_release_sync_reports_not_available_when_newest_equals_current() {
1854 let base = stub(|_| {
1858 vec![Resp {
1859 status: "200 OK",
1860 link: None,
1861 body: release_obj_json("v1.0.0"),
1862 }]
1863 });
1864 let upd = github_update_sync(&base, "1.0.0");
1865 let releases = upd.get_latest_release().unwrap();
1866 assert_eq!(releases.latest().unwrap().version(), "1.0.0");
1867 assert!(
1868 !releases.is_update_available().unwrap(),
1869 "newest (1.0.0) == current => not available on the one-element path"
1870 );
1871 }
1872
1873 #[test]
1874 fn update_extended_sync_reports_up_to_date_through_the_orchestrator() {
1875 let base = stub(|_| {
1881 vec![Resp {
1882 status: "200 OK",
1883 link: None,
1884 body: release_json("v1.0.0"),
1885 }]
1886 });
1887 let upd = super::Update::configure()
1888 .repo_owner("o")
1889 .repo_name("r")
1890 .bin_name("app")
1891 .current_version("2.0.0")
1892 .api_base_url(&base)
1893 .no_confirm(true)
1894 .show_output(false)
1895 .build()
1896 .unwrap();
1897 let status = upd.update_extended().unwrap();
1898 assert!(
1899 status.is_up_to_date(),
1900 "an older listed release means up-to-date through the sync orchestrator"
1901 );
1902 }
1903
1904 #[test]
1905 fn fetch_all_releases_follows_link_pagination() {
1906 let base = stub(|base| {
1908 vec![
1909 Resp {
1910 status: "200 OK",
1911 link: Some(format!("{base}/releases?page=2")),
1912 body: release_json("v1.0.0"),
1913 },
1914 Resp {
1915 status: "200 OK",
1916 link: None,
1917 body: release_json("v0.9.0"),
1918 },
1919 ]
1920 });
1921 let releases = fetch_all_releases(
1922 &format!("{base}/releases"),
1923 &crate::backends::common::RequestConfig::default(),
1924 )
1925 .unwrap();
1926 assert_eq!(
1927 releases.len(),
1928 2,
1929 "releases from both pages are accumulated"
1930 );
1931 assert_eq!(releases[0].version(), "1.0.0");
1932 assert_eq!(releases[1].version(), "0.9.0");
1933 }
1934
1935 #[test]
1936 fn fetch_all_releases_errors_on_http_error_status() {
1937 let base = stub(|_| {
1938 vec![Resp {
1939 status: "404 Not Found",
1940 link: None,
1941 body: "nope".to_string(),
1942 }]
1943 });
1944 let res = fetch_all_releases(
1945 &format!("{base}/releases"),
1946 &crate::backends::common::RequestConfig::default(),
1947 );
1948 assert!(matches!(
1951 res,
1952 Err(crate::errors::Error::NotFound { .. })
1953 | Err(crate::errors::Error::Unauthorized { .. })
1954 | Err(crate::errors::Error::HttpStatus { .. })
1955 ));
1956 }
1957
1958 #[test]
1959 fn fetch_all_releases_errors_when_body_is_not_an_array() {
1960 let base = stub(|_| {
1961 vec![Resp {
1962 status: "200 OK",
1963 link: None,
1964 body: "{}".to_string(),
1965 }]
1966 });
1967 let res = fetch_all_releases(
1968 &format!("{base}/releases"),
1969 &crate::backends::common::RequestConfig::default(),
1970 );
1971 assert!(
1972 matches!(res, Err(crate::errors::Error::InvalidResponse { .. })),
1973 "a non-array listing body must surface as Error::InvalidResponse, got {:?}",
1974 res
1975 );
1976 }
1977
1978 fn stub_capturing(
1981 make: impl FnOnce(&str) -> Vec<Resp>,
1982 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
1983 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1984 let base = format!("http://{}", listener.local_addr().unwrap());
1985 let responses = make(&base);
1986 let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1987 let sink = captured.clone();
1988 std::thread::spawn(move || {
1989 for r in responses {
1990 let (mut stream, _) = match listener.accept() {
1991 Ok(c) => c,
1992 Err(_) => return,
1993 };
1994 let mut buf = [0u8; 4096];
1995 let n = stream.read(&mut buf).unwrap_or(0);
1996 sink.lock()
1997 .unwrap()
1998 .push(String::from_utf8_lossy(&buf[..n]).into_owned());
1999 let mut out = format!(
2000 "HTTP/1.1 {}\r\nContent-Type: application/json\r\n",
2001 r.status
2002 );
2003 if let Some(link) = r.link {
2004 out.push_str(&format!("Link: <{link}>; rel=\"next\"\r\n"));
2005 }
2006 out.push_str(&format!(
2007 "Content-Length: {}\r\nConnection: close\r\n\r\n{}",
2008 r.body.len(),
2009 r.body
2010 ));
2011 let _ = stream.write_all(out.as_bytes());
2012 let _ = stream.flush();
2013 }
2014 });
2015 (base, captured)
2016 }
2017
2018 #[test]
2019 fn get_release_version_percent_encodes_the_tag_in_the_url() {
2020 let (base, captured) = stub_capturing(|_| {
2024 vec![Resp {
2025 status: "200 OK",
2026 link: None,
2027 body: release_obj_json("v1.0.0+build.5"),
2028 }]
2029 });
2030 let upd = super::Update::configure()
2031 .repo_owner("o")
2032 .repo_name("r")
2033 .bin_name("app")
2034 .current_version("0.1.0")
2035 .api_base_url(&base)
2036 .build()
2037 .unwrap();
2038 let rel = upd.get_release_version("v1.0.0+build.5").unwrap();
2039 assert_eq!(rel.version(), "1.0.0+build.5");
2040 let request = &captured.lock().unwrap()[0];
2041 let request_line = request.lines().next().unwrap_or_default();
2042 assert!(
2043 request_line.contains("/releases/tags/v1.0.0%2Bbuild.5"),
2044 "tag should be percent-encoded in the request path, got: {}",
2045 request_line
2046 );
2047 assert!(
2048 !request_line.contains("v1.0.0+build.5"),
2049 "raw unencoded `+` must not reach the request path, got: {}",
2050 request_line
2051 );
2052 }
2053
2054 #[test]
2055 fn urls_percent_encode_repo_owner_and_name() {
2056 let (base, captured) = stub_capturing(|_| {
2061 vec![Resp {
2062 status: "200 OK",
2063 link: None,
2064 body: release_obj_json("v1.0.0"),
2065 }]
2066 });
2067 let upd = super::Update::configure()
2068 .repo_owner("own er")
2069 .repo_name("re#po")
2070 .bin_name("app")
2071 .current_version("0.1.0")
2072 .api_base_url(&base)
2073 .build()
2074 .unwrap();
2075 let _ = upd.get_release_version("v1.0.0").unwrap();
2076 let request = &captured.lock().unwrap()[0];
2077 let request_line = request.lines().next().unwrap_or_default();
2078 assert!(
2079 request_line.contains("/repos/own%20er/re%23po/releases/tags/"),
2080 "owner and name must be percent-encoded in the request path, got: {}",
2081 request_line
2082 );
2083 }
2084
2085 #[test]
2086 fn builder_stores_timeout_and_request_header() {
2087 use std::time::Duration;
2088 let upd = super::Update::configure()
2089 .repo_owner("o")
2090 .repo_name("r")
2091 .bin_name("app")
2092 .current_version("0.1.0")
2093 .timeout(Duration::from_secs(7))
2094 .request_header("x-foo", "bar")
2097 .build()
2098 .unwrap();
2099 assert_eq!(upd.request_timeout(), Some(Duration::from_secs(7)));
2100 assert_eq!(
2101 upd.request_headers()
2102 .get("x-foo")
2103 .unwrap()
2104 .to_str()
2105 .unwrap(),
2106 "bar"
2107 );
2108 }
2109
2110 #[test]
2111 fn request_header_accepts_typed_args() {
2112 use crate::http_client::header::{HeaderName, HeaderValue};
2113 let upd = super::Update::configure()
2116 .repo_owner("o")
2117 .repo_name("r")
2118 .bin_name("app")
2119 .current_version("0.1.0")
2120 .request_header(
2121 HeaderName::from_static("x-typed"),
2122 HeaderValue::from_static("v"),
2123 )
2124 .build()
2125 .unwrap();
2126 assert_eq!(upd.request_headers().get("x-typed").unwrap(), "v");
2127 }
2128
2129 #[test]
2130 fn api_headers_override_uses_github_user_agent() {
2131 let upd = super::Update::configure()
2137 .repo_owner("o")
2138 .repo_name("r")
2139 .bin_name("app")
2140 .current_version("0.1.0")
2141 .build()
2142 .unwrap();
2143 let headers = upd.api_headers(Some("secret")).unwrap();
2144 assert_eq!(
2145 headers
2146 .get(crate::http_client::header::USER_AGENT)
2147 .unwrap()
2148 .to_str()
2149 .unwrap(),
2150 crate::DEFAULT_USER_AGENT
2151 );
2152 assert!(
2153 headers
2154 .get(crate::http_client::header::AUTHORIZATION)
2155 .is_none(),
2156 "api_headers no longer bakes auth; apply_auth applies the scheme"
2157 );
2158 }
2159
2160 #[cfg(feature = "reqwest")]
2165 #[test]
2166 fn add_root_certificate_bad_cert_surfaces_from_build() {
2167 const BAD_PEM: &[u8] =
2168 b"-----BEGIN CERTIFICATE-----\nbm90IGEgdmFsaWQgY2VydA==\n-----END CERTIFICATE-----\n";
2169 let res = super::Update::configure()
2170 .repo_owner("o")
2171 .repo_name("r")
2172 .bin_name("app")
2173 .current_version("0.1.0")
2174 .add_root_certificate(crate::Certificate::from_pem(BAD_PEM.to_vec()))
2175 .build();
2176 assert!(
2177 matches!(res, Err(crate::errors::Error::InvalidCertificate { .. })),
2178 "a bad cert must surface as InvalidCertificate from Update build(), got {:?}",
2179 res.map(|_| "Ok")
2180 );
2181 let res = super::ReleaseList::configure()
2182 .repo_owner("o")
2183 .repo_name("r")
2184 .add_root_certificate(crate::Certificate::from_pem(BAD_PEM.to_vec()))
2185 .build();
2186 assert!(
2187 matches!(res, Err(crate::errors::Error::InvalidCertificate { .. })),
2188 "a bad cert must surface as InvalidCertificate from ReleaseList build(), got {:?}",
2189 res.map(|_| "Ok")
2190 );
2191 }
2192
2193 #[test]
2199 fn proxy_bad_url_surfaces_from_build() {
2200 let res = super::Update::configure()
2201 .repo_owner("o")
2202 .repo_name("r")
2203 .bin_name("app")
2204 .current_version("0.1.0")
2205 .proxy("http://corpuser:hunter2@ not a proxy url")
2206 .build();
2207 let err = res
2208 .map(|_| "Ok")
2209 .expect_err("an unparseable proxy must fail build()");
2210 assert!(
2211 matches!(err, crate::errors::Error::InvalidProxy { .. }),
2212 "a bad proxy URL must surface as InvalidProxy from Update build(), got {err:?}"
2213 );
2214 let rendered = err.to_string();
2215 assert!(
2216 !rendered.contains("hunter2") && rendered.contains("REDACTED"),
2217 "the proxy password must not reach the error text, got: {rendered}"
2218 );
2219 let res = super::ReleaseList::configure()
2220 .repo_owner("o")
2221 .repo_name("r")
2222 .proxy("http://corpuser:hunter2@ not a proxy url")
2223 .build();
2224 assert!(
2225 matches!(res, Err(crate::errors::Error::InvalidProxy { .. })),
2226 "a bad proxy URL must surface as InvalidProxy from ReleaseList build(), got {:?}",
2227 res.map(|_| "Ok")
2228 );
2229 }
2230
2231 #[test]
2235 fn proxy_is_stored_and_last_call_wins() {
2236 let upd = super::Update::configure()
2237 .repo_owner("o")
2238 .repo_name("r")
2239 .bin_name("app")
2240 .current_version("0.1.0")
2241 .proxy("http://first.proxy:8080")
2242 .proxy("http://second.proxy:8080")
2243 .build()
2244 .expect("a valid proxy must build");
2245 assert_eq!(
2246 upd.request_config().proxy.as_deref(),
2247 Some("http://second.proxy:8080"),
2248 "the last `proxy` call must win"
2249 );
2250 }
2251
2252 #[test]
2258 fn asset_download_url_comes_from_the_api_url_field() {
2259 let dto: super::ReleaseDto = serde_json::from_str(
2260 r#"{"tag_name":"v1.2.3","created_at":"2024-01-01T00:00:00Z","name":"1.2.3","assets":[
2261 {"name":"app.tar.gz",
2262 "url":"https://api.github.com/repos/o/r/releases/assets/1",
2263 "browser_download_url":"https://github.com/o/r/releases/download/v1.2.3/app.tar.gz"}]}"#,
2264 )
2265 .expect("parse release json");
2266 let release = dto.into_release(None).expect("into_release");
2267 assert_eq!(
2268 release.assets()[0].download_url(),
2269 "https://api.github.com/repos/o/r/releases/assets/1",
2270 "the asset URL must be the API `url`, which serves private assets too"
2271 );
2272 }
2273
2274 #[test]
2278 fn github_token_scheme_applied_to_both_paths() {
2279 use crate::http_client::header::{AUTHORIZATION, HeaderMap};
2280 let upd = super::Update::configure()
2281 .repo_owner("o")
2282 .repo_name("r")
2283 .bin_name("app")
2284 .current_version("0.1.0")
2285 .auth_token("secret")
2286 .build()
2287 .unwrap();
2288 let mut headers = HeaderMap::new();
2289 upd.request_config()
2290 .apply_auth(
2291 "https://api.github.com/repos/o/r/releases/assets/1",
2292 &mut headers,
2293 )
2294 .unwrap();
2295 assert_eq!(
2296 headers.get(AUTHORIZATION).unwrap().to_str().unwrap(),
2297 "token secret",
2298 "github authenticates with the token scheme"
2299 );
2300
2301 let upd = super::Update::configure()
2303 .repo_owner("o")
2304 .repo_name("r")
2305 .bin_name("app")
2306 .current_version("0.1.0")
2307 .auth_token("secret")
2308 .request_header(AUTHORIZATION, "Bearer user-override")
2309 .build()
2310 .unwrap();
2311 let mut headers = upd.request_config().headers.clone();
2312 upd.request_config()
2313 .apply_auth(
2314 "https://api.github.com/repos/o/r/releases/assets/1",
2315 &mut headers,
2316 )
2317 .unwrap();
2318 assert_eq!(
2319 headers.get(AUTHORIZATION).unwrap().to_str().unwrap(),
2320 "Bearer user-override",
2321 "a user AUTHORIZATION override must win over the backend token scheme"
2322 );
2323 }
2324
2325 #[test]
2329 fn invalid_auth_token_chains_source() {
2330 use crate::http_client::header::HeaderMap;
2331 use std::error::Error as _;
2332 let upd = super::Update::configure()
2333 .repo_owner("o")
2334 .repo_name("r")
2335 .bin_name("app")
2336 .current_version("0.1.0")
2337 .auth_token("bad\nvalue")
2338 .build()
2339 .unwrap();
2340 let mut headers = HeaderMap::new();
2341 let err = upd
2342 .request_config()
2343 .apply_auth(
2344 "https://api.github.com/repos/o/r/releases/assets/1",
2345 &mut headers,
2346 )
2347 .expect_err("an unencodable auth token must error");
2348 assert!(
2349 matches!(err, crate::errors::Error::InvalidAuthToken { .. }),
2350 "expected Error::InvalidAuthToken, got {:?}",
2351 err
2352 );
2353 assert!(
2354 err.source().is_some(),
2355 "InvalidAuthToken must chain a non-None source()"
2356 );
2357 }
2358
2359 #[test]
2360 fn request_header_surfaces_invalid_header_at_build() {
2361 let res = super::Update::configure()
2364 .repo_owner("o")
2365 .repo_name("r")
2366 .bin_name("app")
2367 .current_version("0.1.0")
2368 .request_header("inva lid name", "ok")
2369 .build();
2370 assert!(
2371 matches!(res, Err(crate::errors::Error::InvalidHeader { .. })),
2372 "invalid header name should surface as Error::InvalidHeader from build()"
2373 );
2374 }
2375
2376 #[test]
2377 fn builder_stores_progress_callback() {
2378 let upd = super::Update::configure()
2379 .repo_owner("o")
2380 .repo_name("r")
2381 .bin_name("app")
2382 .current_version("0.1.0")
2383 .progress_callback(|_downloaded, _total| {})
2384 .build()
2385 .unwrap();
2386 assert!(upd.progress_callback().is_some());
2388 }
2389
2390 #[test]
2391 fn builder_stores_verify_hook() {
2392 let upd = super::Update::configure()
2393 .repo_owner("o")
2394 .repo_name("r")
2395 .bin_name("app")
2396 .current_version("0.1.0")
2397 .verify_binary(|_new_exe| Ok(()))
2398 .build()
2399 .unwrap();
2400 assert!(upd.verify_callback().is_some());
2401 }
2402
2403 #[test]
2404 #[cfg(feature = "checksums")]
2405 fn builder_stores_checksum() {
2406 let upd = super::Update::configure()
2407 .repo_owner("o")
2408 .repo_name("r")
2409 .bin_name("app")
2410 .current_version("0.1.0")
2411 .verify_checksum(crate::Checksum::Sha256("ab".repeat(32)))
2412 .build()
2413 .unwrap();
2414 assert!(upd.verify_checksum().is_some());
2415 }
2416
2417 #[test]
2421 fn builder_stores_bundle_paths() {
2422 let plain = super::Update::configure()
2423 .repo_owner("o")
2424 .repo_name("r")
2425 .bin_name("app")
2426 .current_version("0.1.0")
2427 .build()
2428 .unwrap();
2429 assert!(
2430 plain.bundle_path_in_archive().is_none() && plain.bundle_install_path().is_none(),
2431 "bundle mode must be off unless bundle_path_in_archive is set"
2432 );
2433
2434 let bundled = super::Update::configure()
2435 .repo_owner("o")
2436 .repo_name("r")
2437 .bin_name("app")
2438 .current_version("0.1.0")
2439 .bundle_path_in_archive("{{ bin }}-{{ version }}/MyApp.app")
2440 .bundle_install_path("/Applications/MyApp.app")
2441 .build()
2442 .unwrap();
2443 assert_eq!(
2444 bundled.bundle_path_in_archive(),
2445 Some("{{ bin }}-{{ version }}/MyApp.app")
2446 );
2447 assert_eq!(
2448 bundled.bundle_install_path(),
2449 Some(std::path::Path::new("/Applications/MyApp.app"))
2450 );
2451 }
2452
2453 #[test]
2458 fn builder_rejects_bundle_mode_combined_with_the_single_file_setters() {
2459 let conflict = |res: crate::errors::Result<super::Update>| match res {
2460 Err(crate::errors::Error::ConflictingConfig { field, conflict }) => {
2461 assert_eq!(field, "bundle_path_in_archive");
2462 conflict
2463 }
2464 other => panic!("expected ConflictingConfig, got {:?}", other.map(|_| ())),
2465 };
2466
2467 let res = super::Update::configure()
2468 .repo_owner("o")
2469 .repo_name("r")
2470 .bin_name("app")
2471 .current_version("0.1.0")
2472 .bundle_path_in_archive("MyApp.app")
2473 .bundle_install_path("/Applications/MyApp.app")
2474 .bin_install_path("/usr/local/bin/app")
2475 .build();
2476 assert_eq!(conflict(res), "bin_install_path");
2477
2478 let res = super::Update::configure()
2479 .repo_owner("o")
2480 .repo_name("r")
2481 .bin_name("app")
2482 .current_version("0.1.0")
2483 .bundle_path_in_archive("MyApp.app")
2484 .bundle_install_path("/Applications/MyApp.app")
2485 .bin_path_in_archive("app")
2487 .build();
2488 assert_eq!(conflict(res), "bin_path_in_archive");
2489 }
2490
2491 #[test]
2492 fn builder_stores_asset_matcher() {
2493 let upd = super::Update::configure()
2494 .repo_owner("o")
2495 .repo_name("r")
2496 .bin_name("app")
2497 .current_version("0.1.0")
2498 .asset_matcher(|assets| assets.first().cloned())
2499 .build()
2500 .unwrap();
2501 assert!(upd.asset_matcher().is_some());
2502 }
2503
2504 #[test]
2505 fn asset_matcher_overrides_default_selection() {
2506 use crate::update::{Release, ReleaseAsset};
2507
2508 let release = Release::builder()
2510 .version("1.0.0")
2511 .assets([
2512 ReleaseAsset::new("app-stable.bin", "https://example/stable"),
2513 ReleaseAsset::new("app-nightly.bin", "https://example/nightly"),
2514 ])
2515 .build()
2516 .unwrap();
2517
2518 assert!(release.asset_for("some-unmatchable-target", None).is_none());
2520
2521 let upd = super::Update::configure()
2523 .repo_owner("o")
2524 .repo_name("r")
2525 .bin_name("app")
2526 .current_version("0.1.0")
2527 .asset_matcher(|assets| assets.iter().find(|a| a.name.contains("nightly")).cloned())
2528 .build()
2529 .unwrap();
2530 let matcher = upd.asset_matcher().expect("matcher stored");
2531 let chosen = matcher(release.assets()).expect("matcher selects an asset");
2532 assert_eq!(chosen.name(), "app-nightly.bin");
2533 assert_eq!(chosen.download_url(), "https://example/nightly");
2534 }
2535
2536 #[cfg(feature = "reqwest")]
2537 #[test]
2538 fn builder_stores_reqwest_client() {
2539 let client = reqwest::blocking::Client::builder().build().unwrap();
2540 let upd = super::Update::configure()
2541 .repo_owner("o")
2542 .repo_name("r")
2543 .bin_name("app")
2544 .current_version("0.1.0")
2545 .reqwest_client(client)
2546 .build()
2547 .unwrap();
2548 assert!(upd.request_client().is_some());
2551 }
2552
2553 #[cfg(feature = "reqwest")]
2556 fn marker_default_headers() -> crate::http_client::header::HeaderMap {
2557 use crate::http_client::header::{HeaderMap, HeaderName, HeaderValue};
2558 let mut headers = HeaderMap::new();
2559 headers.insert(
2560 HeaderName::from_static("x-injected-client"),
2561 HeaderValue::from_static("marker"),
2562 );
2563 headers
2564 }
2565
2566 #[cfg(feature = "reqwest")]
2567 #[test]
2568 fn injected_reqwest_client_is_used_on_the_wire() {
2569 use crate::backends::common::RequestConfig;
2570 let (base, captured) = stub_capturing(|_| {
2571 vec![Resp {
2572 status: "200 OK",
2573 link: None,
2574 body: release_json("v1.2.3"),
2575 }]
2576 });
2577 let client = reqwest::blocking::Client::builder()
2579 .default_headers(marker_default_headers())
2580 .build()
2581 .unwrap();
2582 let cfg = RequestConfig {
2583 client: Some(std::sync::Arc::new(
2584 crate::http_client::ReqwestClient::from(client),
2585 )),
2586 ..Default::default()
2587 };
2588 let releases = fetch_all_releases(&format!("{base}/releases"), &cfg).unwrap();
2589 assert_eq!(releases.len(), 1);
2590 assert_eq!(releases[0].version(), "1.2.3");
2591 let request = captured.lock().unwrap()[0].to_lowercase();
2592 assert!(
2593 request.contains("x-injected-client: marker"),
2594 "the injected client's default header proves it was used (not a fresh client)"
2595 );
2596 }
2597
2598 #[cfg(feature = "async")]
2599 #[test]
2600 fn builder_stores_reqwest_async_client() {
2601 let client = reqwest::Client::builder().build().unwrap();
2602 let upd = super::Update::configure()
2603 .repo_owner("o")
2604 .repo_name("r")
2605 .bin_name("app")
2606 .current_version("0.1.0")
2607 .reqwest_async_client(client)
2608 .build()
2609 .unwrap();
2610 assert!(upd.request_async_client().is_some());
2611 }
2612
2613 #[cfg(feature = "async")]
2614 #[tokio::test]
2615 async fn injected_async_client_is_used_on_the_wire() {
2616 use crate::backends::common::RequestConfig;
2617 let (base, captured) = stub_capturing(|_| {
2618 vec![Resp {
2619 status: "200 OK",
2620 link: None,
2621 body: release_json("v2.0.0"),
2622 }]
2623 });
2624 let client = reqwest::Client::builder()
2625 .default_headers(marker_default_headers())
2626 .build()
2627 .unwrap();
2628 let cfg = RequestConfig {
2629 async_client: Some(std::sync::Arc::new(
2630 crate::http_client::ReqwestAsyncClient::from(client),
2631 )),
2632 ..Default::default()
2633 };
2634 let releases = fetch_all_releases_async(&format!("{base}/releases"), &cfg)
2635 .await
2636 .unwrap();
2637 assert_eq!(releases.len(), 1);
2638 assert_eq!(releases[0].version(), "2.0.0");
2639 let request = captured.lock().unwrap()[0].to_lowercase();
2640 assert!(
2641 request.contains("x-injected-client: marker"),
2642 "the injected async client's default header proves it was used"
2643 );
2644 }
2645
2646 #[cfg(feature = "ureq")]
2647 #[test]
2648 fn injected_ureq_agent_is_used_on_the_wire() {
2649 use crate::backends::common::RequestConfig;
2650 let base = stub(|_| {
2651 vec![Resp {
2652 status: "200 OK",
2653 link: None,
2654 body: release_json("v3.0.0"),
2655 }]
2656 });
2657 let agent = ureq::Agent::new_with_config(ureq::Agent::config_builder().build());
2658 let upd = super::Update::configure()
2659 .repo_owner("o")
2660 .repo_name("r")
2661 .bin_name("app")
2662 .current_version("0.1.0")
2663 .ureq_agent(agent)
2664 .build()
2665 .unwrap();
2666 assert!(upd.request_client().is_some());
2667
2668 let agent = ureq::Agent::new_with_config(ureq::Agent::config_builder().build());
2670 let cfg = RequestConfig {
2671 client: Some(std::sync::Arc::new(crate::http_client::UreqClient::from(
2672 agent,
2673 ))),
2674 ..Default::default()
2675 };
2676 let releases = fetch_all_releases(&format!("{base}/releases"), &cfg).unwrap();
2677 assert_eq!(releases.len(), 1);
2678 assert_eq!(releases[0].version(), "3.0.0");
2679 }
2680
2681 struct FakeResponse {
2687 body: String,
2688 headers: crate::http_client::HeaderMap,
2689 }
2690
2691 impl crate::http_client::HttpResponse for FakeResponse {
2692 fn headers(&self) -> &crate::http_client::HeaderMap {
2693 &self.headers
2694 }
2695 fn body(self: Box<Self>) -> Box<dyn std::io::Read> {
2696 Box::new(std::io::Cursor::new(self.body.into_bytes()))
2697 }
2698 }
2699
2700 struct FakeClient {
2704 body: String,
2705 requested: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
2706 }
2707
2708 impl crate::http_client::HttpClient for FakeClient {
2709 fn get(
2710 &self,
2711 url: &str,
2712 _headers: &crate::http_client::HeaderMap,
2713 _timeout: Option<std::time::Duration>,
2714 ) -> crate::errors::Result<Box<dyn crate::http_client::HttpResponse>> {
2715 self.requested.lock().unwrap().push(url.to_string());
2716 Ok(Box::new(FakeResponse {
2717 body: self.body.clone(),
2718 headers: crate::http_client::HeaderMap::new(),
2719 }))
2720 }
2721 }
2722
2723 #[test]
2724 fn injected_fake_http_client_drives_a_backend_through_the_trait() {
2725 use crate::backends::common::RequestConfig;
2730 let requested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2731 let cfg = RequestConfig {
2732 client: Some(std::sync::Arc::new(FakeClient {
2733 body: release_json("v4.5.6"),
2734 requested: requested.clone(),
2735 })),
2736 ..Default::default()
2737 };
2738 let releases = fetch_all_releases("https://example.test/repos/o/r/releases", &cfg).unwrap();
2739 assert_eq!(releases.len(), 1);
2740 assert_eq!(
2741 releases[0].version(),
2742 "4.5.6",
2743 "the backend parsed the fake client's canned body through the trait"
2744 );
2745 let urls = requested.lock().unwrap();
2746 assert_eq!(urls.len(), 1, "exactly one request was issued");
2747 assert!(
2748 urls[0].contains("/repos/o/r/releases"),
2749 "the fake client recorded the URL the backend requested through the trait, got {:?}",
2750 urls[0]
2751 );
2752 }
2753
2754 struct HeaderCapturingClient(
2757 std::sync::Arc<std::sync::Mutex<Vec<crate::http_client::HeaderMap>>>,
2758 );
2759
2760 impl crate::http_client::HttpClient for HeaderCapturingClient {
2761 fn get(
2762 &self,
2763 _url: &str,
2764 headers: &crate::http_client::HeaderMap,
2765 _timeout: Option<std::time::Duration>,
2766 ) -> crate::errors::Result<Box<dyn crate::http_client::HttpResponse>> {
2767 self.0.lock().unwrap().push(headers.clone());
2768 Ok(Box::new(FakeResponse {
2769 body: "[]".to_string(),
2770 headers: crate::http_client::HeaderMap::new(),
2771 }))
2772 }
2773 }
2774
2775 #[test]
2782 fn a_user_supplied_authorization_header_reaches_the_transport_intact_and_sensitive() {
2783 use crate::backends::common::RequestConfig;
2784 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2785 let mut cfg = RequestConfig {
2786 auth_token: Some("backend-token".to_string()),
2789 auth_base_host: Some("example.test".to_string()),
2790 client: Some(std::sync::Arc::new(HeaderCapturingClient(seen.clone()))),
2791 ..Default::default()
2792 };
2793 cfg.insert_header("Authorization", "Bearer user-supplied-secret");
2794 let _ = fetch_all_releases("https://example.test/repos/o/r/releases", &cfg).unwrap();
2795
2796 let seen = seen.lock().unwrap();
2797 assert_eq!(seen.len(), 1, "exactly one request must have been issued");
2798 let value = seen[0]
2799 .get(crate::http_client::header::AUTHORIZATION)
2800 .expect("the user-supplied Authorization header must reach the transport");
2801 assert_eq!(
2802 value.to_str().unwrap(),
2803 "Bearer user-supplied-secret",
2804 "the user's header wins over the backend token and is sent verbatim"
2805 );
2806 assert!(
2807 value.is_sensitive(),
2808 "the sensitivity marking must survive the header merge, or the credential still shows \
2809 up in a transport's request logging"
2810 );
2811 }
2812
2813 #[test]
2814 fn http_traits_are_object_safe() {
2815 let _client: Box<dyn crate::http_client::HttpClient> = Box::new(FakeClient {
2819 body: "[]".to_string(),
2820 requested: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
2821 });
2822 let _resp: Box<dyn crate::http_client::HttpResponse> = Box::new(FakeResponse {
2823 body: "[]".to_string(),
2824 headers: crate::http_client::HeaderMap::new(),
2825 });
2826 let _arc: std::sync::Arc<dyn crate::http_client::HttpClient> =
2828 std::sync::Arc::new(FakeClient {
2829 body: "[]".to_string(),
2830 requested: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
2831 });
2832 }
2833
2834 #[test]
2835 fn request_header_is_sent_on_the_wire() {
2836 use crate::backends::common::RequestConfig;
2837 use crate::http_client::header::{HeaderMap, HeaderName, HeaderValue};
2838 let (base, captured) = stub_capturing(|_| {
2839 vec![Resp {
2840 status: "200 OK",
2841 link: None,
2842 body: release_json("v1.0.0"),
2843 }]
2844 });
2845 let mut headers = HeaderMap::new();
2846 headers.insert(
2847 HeaderName::from_static("x-custom"),
2848 HeaderValue::from_static("hello"),
2849 );
2850 let cfg = RequestConfig {
2851 timeout: None,
2852 headers,
2853 ..Default::default()
2854 };
2855 let releases = fetch_all_releases(&format!("{base}/releases"), &cfg).unwrap();
2856 assert_eq!(releases.len(), 1);
2857 let request = captured.lock().unwrap()[0].to_lowercase();
2858 assert!(
2859 request.contains("x-custom: hello"),
2860 "custom header missing from request:\n{}",
2861 captured.lock().unwrap()[0]
2862 );
2863 }
2864
2865 #[test]
2866 fn timeout_aborts_an_unresponsive_request() {
2867 use crate::backends::common::RequestConfig;
2868 use std::time::{Duration, Instant};
2869 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2871 let base = format!("http://{}", listener.local_addr().unwrap());
2872 std::thread::spawn(move || {
2873 let _held = listener.accept();
2874 std::thread::sleep(Duration::from_secs(5));
2875 });
2876 let cfg = RequestConfig {
2877 timeout: Some(Duration::from_millis(200)),
2878 ..Default::default()
2879 };
2880 let start = Instant::now();
2881 let res = fetch_all_releases(&format!("{base}/releases"), &cfg);
2882 assert!(res.is_err(), "expected a timeout error");
2883 assert!(
2884 start.elapsed() < Duration::from_secs(3),
2885 "request should have timed out quickly, took {:?}",
2886 start.elapsed()
2887 );
2888 }
2889
2890 #[test]
2891 fn retries_recover_from_transient_failures() {
2892 use crate::backends::common::RequestConfig;
2893 let base = stub(|_| {
2895 vec![
2896 Resp {
2897 status: "503 Service Unavailable",
2898 link: None,
2899 body: "busy".to_string(),
2900 },
2901 Resp {
2902 status: "503 Service Unavailable",
2903 link: None,
2904 body: "busy".to_string(),
2905 },
2906 Resp {
2907 status: "200 OK",
2908 link: None,
2909 body: release_json("v1.0.0"),
2910 },
2911 ]
2912 });
2913 let cfg = RequestConfig {
2914 retries: 2,
2915 ..Default::default()
2916 };
2917 let releases = fetch_all_releases(&format!("{base}/releases"), &cfg).unwrap();
2918 assert_eq!(releases.len(), 1);
2919 assert_eq!(releases[0].version(), "1.0.0");
2920 }
2921
2922 #[test]
2923 fn retries_are_exhausted_and_then_error() {
2924 use crate::backends::common::RequestConfig;
2925 let base = stub(|_| {
2927 vec![
2928 Resp {
2929 status: "503 Service Unavailable",
2930 link: None,
2931 body: "busy".to_string(),
2932 },
2933 Resp {
2934 status: "503 Service Unavailable",
2935 link: None,
2936 body: "busy".to_string(),
2937 },
2938 ]
2939 });
2940 let cfg = RequestConfig {
2941 retries: 1,
2942 ..Default::default()
2943 };
2944 let res = fetch_all_releases(&format!("{base}/releases"), &cfg);
2945 assert!(res.is_err());
2946 }
2947
2948 #[test]
2949 fn release_list_applies_its_request_config() {
2950 let base = stub(|_| {
2952 vec![
2953 Resp {
2954 status: "503 Service Unavailable",
2955 link: None,
2956 body: "busy".to_string(),
2957 },
2958 Resp {
2959 status: "200 OK",
2960 link: None,
2961 body: release_json("v2.0.0"),
2962 },
2963 ]
2964 });
2965 let releases = super::ReleaseList::configure()
2966 .api_base_url(&base)
2967 .repo_owner("o")
2968 .repo_name("r")
2969 .retries(1)
2970 .build()
2971 .unwrap()
2972 .fetch()
2973 .unwrap()
2974 .into_vec();
2975 assert_eq!(releases.len(), 1);
2976 assert_eq!(releases[0].version(), "2.0.0");
2977 }
2978
2979 #[test]
2982 fn unattended_sets_no_confirm_and_hides_output() {
2983 let upd_default = super::Update::configure()
2985 .repo_owner("o")
2986 .repo_name("r")
2987 .bin_name("app")
2988 .current_version("0.1.0")
2989 .build()
2990 .unwrap();
2991 assert!(
2992 !upd_default.no_confirm(),
2993 "default no_confirm must be false"
2994 );
2995 assert!(
2996 upd_default.show_output(),
2997 "default show_output must be true"
2998 );
2999
3000 let upd = super::Update::configure()
3002 .repo_owner("o")
3003 .repo_name("r")
3004 .bin_name("app")
3005 .current_version("0.1.0")
3006 .unattended()
3007 .build()
3008 .unwrap();
3009 assert!(upd.no_confirm(), "unattended() must set no_confirm to true");
3010 assert!(
3011 !upd.show_output(),
3012 "unattended() must set show_output to false"
3013 );
3014 }
3015
3016 #[test]
3020 fn built_update_is_send() {
3021 fn assert_send<T: Send>() {}
3022 assert_send::<super::Update>();
3023 let upd = super::Update::configure()
3024 .repo_owner("o")
3025 .repo_name("r")
3026 .bin_name("app")
3027 .current_version("0.1.0")
3028 .build()
3029 .unwrap();
3030 std::thread::spawn(move || {
3032 let _ = &upd;
3033 })
3034 .join()
3035 .unwrap();
3036 }
3037
3038 #[cfg(feature = "signatures")]
3041 #[test]
3042 fn builder_stores_verify_keys() {
3043 let key_bytes = [0u8; 32];
3046 let upd = super::Update::configure()
3047 .repo_owner("o")
3048 .repo_name("r")
3049 .bin_name("app")
3050 .current_version("0.1.0")
3051 .verifying_keys([key_bytes])
3052 .build()
3053 .unwrap();
3054 assert_eq!(
3055 upd.verifying_keys().len(),
3056 1,
3057 "verifying_keys() must return the key that was set"
3058 );
3059 assert_eq!(
3060 upd.verifying_keys()[0],
3061 key_bytes,
3062 "returned key bytes must match what was supplied"
3063 );
3064 }
3065
3066 #[test]
3069 fn api_headers_takes_no_auth_param_and_sets_user_agent() {
3070 let headers = super::api_headers().unwrap();
3075 assert_eq!(
3076 headers
3077 .get(crate::http_client::header::USER_AGENT)
3078 .unwrap()
3079 .to_str()
3080 .unwrap(),
3081 crate::DEFAULT_USER_AGENT,
3082 "api_headers() must set the shared self_update User-Agent"
3083 );
3084 assert!(
3085 headers
3086 .get(crate::http_client::header::AUTHORIZATION)
3087 .is_none(),
3088 "api_headers() must not set an Authorization header"
3089 );
3090 }
3091
3092 #[test]
3093 fn continuation_page_user_agent_header_is_present() {
3094 let (base, captured) = stub_capturing(|base| {
3099 vec![
3100 Resp {
3101 status: "200 OK",
3102 link: Some(format!("{base}/releases?page=2")),
3103 body: release_json("v2.0.0"),
3104 },
3105 Resp {
3106 status: "200 OK",
3107 link: None,
3108 body: release_json("v1.0.0"),
3109 },
3110 ]
3111 });
3112 let releases = fetch_all_releases(
3113 &format!("{base}/releases"),
3114 &crate::backends::common::RequestConfig::default(),
3115 )
3116 .unwrap();
3117 assert_eq!(releases.len(), 2, "both pages must be fetched");
3118 let requests = captured.lock().unwrap();
3119 assert_eq!(
3120 requests.len(),
3121 2,
3122 "exactly two HTTP requests must be issued"
3123 );
3124 let expected_ua = format!("user-agent: {}", crate::DEFAULT_USER_AGENT.to_lowercase());
3126 for (i, req) in requests.iter().enumerate() {
3127 assert!(
3128 req.to_lowercase().contains(&expected_ua),
3129 "page {} request is missing the User-Agent header:\n{}",
3130 i + 1,
3131 req
3132 );
3133 }
3134 }
3135}