1use crate::{Git, Release, SortedSlice, Status, api, git::GITHUB_API_CLIENT};
4pub use binary::*;
5use derivative::Derivative;
6use duct::cmd;
7use flate2::read::GzDecoder;
8use regex::Regex;
9use reqwest::StatusCode;
10use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
11use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
12use std::{
13 collections::HashMap,
14 error::Error as _,
15 fs::{File, copy, metadata, read_dir, rename},
16 io::{BufRead, Seek, SeekFrom, Write},
17 os::unix::fs::PermissionsExt,
18 path::{Path, PathBuf},
19 time::Duration,
20};
21use tar::Archive;
22use tempfile::{tempdir, tempfile};
23use thiserror::Error;
24use url::Url;
25
26mod binary;
27
28#[derive(Error, Debug)]
30pub enum Error {
31 #[error("Anyhow error: {0}")]
33 AnyhowError(#[from] anyhow::Error),
34 #[error("API error: {0}")]
36 ApiError(#[from] api::Error),
37 #[error("Archive error: {0}")]
39 ArchiveError(String),
40 #[error("HTTP error: {0} caused by {:?}", reqwest::Error::source(.0))]
42 HttpError(#[from] reqwest::Error),
43 #[error("HTTP middleware error: {0}")]
45 MiddlewareError(#[from] reqwest_middleware::Error),
46 #[error("IO error: {0}")]
48 IO(#[from] std::io::Error),
49 #[error("Missing binary: {0}")]
51 MissingBinary(String),
52 #[error("ParseError error: {0}")]
54 ParseError(#[from] url::ParseError),
55}
56
57#[derive(Clone, Debug, PartialEq)]
59pub enum Source {
60 #[allow(dead_code)]
62 Archive {
63 url: String,
65 contents: Vec<String>,
67 },
68 Git {
70 url: Url,
72 reference: Option<String>,
74 manifest: Option<PathBuf>,
76 package: String,
78 artifacts: Vec<String>,
80 },
81 GitHub(GitHub),
83 #[allow(dead_code)]
85 Url {
86 url: String,
88 name: String,
90 },
91}
92
93impl Source {
94 pub(super) async fn source(
103 &self,
104 cache: &Path,
105 release: bool,
106 status: &impl Status,
107 verbose: bool,
108 ) -> Result<(), Error> {
109 use Source::*;
110 match self {
111 Archive { url, contents } => {
112 let contents: Vec<_> = contents
113 .iter()
114 .map(|name| ArchiveFileSpec::new(name.into(), Some(cache.join(name)), true))
115 .collect();
116 from_archive(url, &contents, status).await
117 },
118 Git { url, reference, manifest, package, artifacts } => {
119 let artifacts: Vec<_> = artifacts
120 .iter()
121 .map(|name| match reference {
122 Some(version) => (name.as_str(), cache.join(format!("{name}-{version}"))),
123 None => (name.as_str(), cache.join(name)),
124 })
125 .collect();
126 from_git(
127 url.as_str(),
128 reference.as_deref(),
129 manifest.as_ref(),
130 package,
131 &artifacts,
132 release,
133 status,
134 verbose,
135 )
136 .await
137 },
138 GitHub(source) => source.source(cache, release, status, verbose).await,
139 Url { url, name } => from_url(url, &cache.join(name), status).await,
140 }
141 }
142
143 pub async fn resolve(
154 self,
155 name: &str,
156 version: Option<&str>,
157 cache: &Path,
158 cache_filter: impl for<'a> FnOnce(&'a str) -> bool + Copy,
159 ) -> Self {
160 match self {
161 Source::GitHub(github) =>
162 Source::GitHub(github.resolve(name, version, cache, cache_filter).await),
163 _ => self,
164 }
165 }
166}
167
168#[derive(Clone, Debug, Derivative)]
170#[derivative(PartialEq)]
171pub enum GitHub {
172 ReleaseArchive {
174 owner: String,
176 repository: String,
178 tag: Option<String>,
180 tag_pattern: Option<TagPattern>,
183 prerelease: bool,
185 #[derivative(PartialEq = "ignore")]
187 version_comparator: for<'a> fn(&'a mut [String]) -> SortedSlice<'a, String>,
188 fallback: String,
190 archive: String,
192 contents: Vec<ArchiveFileSpec>,
194 latest: Option<String>,
196 },
197 SourceCodeArchive {
199 owner: String,
201 repository: String,
203 reference: Option<String>,
205 manifest: Option<PathBuf>,
207 package: String,
209 artifacts: Vec<String>,
211 },
212}
213
214impl GitHub {
215 async fn source(
225 &self,
226 cache: &Path,
227 release: bool,
228 status: &impl Status,
229 verbose: bool,
230 ) -> Result<(), Error> {
231 use GitHub::*;
232 match self {
233 ReleaseArchive { owner, repository, tag, tag_pattern, archive, contents, .. } => {
234 let base_url = format!("https://github.com/{owner}/{repository}/releases");
236 let url = match tag.as_ref() {
237 Some(tag) => {
238 format!("{base_url}/download/{tag}/{archive}")
239 },
240 None => format!("{base_url}/latest/download/{archive}"),
241 };
242 let contents: Vec<_> = contents
243 .iter()
244 .map(|ArchiveFileSpec { name, target, required }| match tag.as_ref() {
245 Some(tag) => ArchiveFileSpec::new(
246 name.into(),
247 Some(cache.join(format!(
248 "{}-{}",
249 target.as_ref().map_or(name.as_str(), |t| t
250 .to_str()
251 .expect("expected target file name to be valid utf-8")),
252 tag_pattern
253 .as_ref()
254 .and_then(|pattern| pattern.version(tag))
255 .unwrap_or(tag)
256 ))),
257 *required,
258 ),
259 None => ArchiveFileSpec::new(
260 name.into(),
261 Some(cache.join(target.as_ref().map_or(name.as_str(), |t| {
262 t.to_str().expect("expected target file name to be valid utf-8")
263 }))),
264 *required,
265 ),
266 })
267 .collect();
268 from_archive(&url, &contents, status).await
269 },
270 SourceCodeArchive { owner, repository, reference, manifest, package, artifacts } => {
271 let artifacts: Vec<_> = artifacts
272 .iter()
273 .map(|name| match reference {
274 Some(reference) =>
275 (name.as_str(), cache.join(format!("{name}-{reference}"))),
276 None => (name.as_str(), cache.join(name)),
277 })
278 .collect();
279 from_github_archive(
280 owner,
281 repository,
282 reference.as_ref().map(|r| r.as_str()),
283 manifest.as_ref(),
284 package,
285 &artifacts,
286 release,
287 status,
288 verbose,
289 )
290 .await
291 },
292 }
293 }
294
295 async fn resolve(
306 self,
307 name: &str,
308 version: Option<&str>,
309 cache: &Path,
310 cache_filter: impl FnOnce(&str) -> bool + Copy,
311 ) -> Self {
312 match self {
313 Self::ReleaseArchive {
314 owner,
315 repository,
316 tag: _,
317 tag_pattern,
318 prerelease,
319 version_comparator,
320 fallback,
321 archive,
322 contents,
323 latest: _,
324 } => {
325 let repo = crate::GitHub::new(owner.as_str(), repository.as_str());
327 let mut releases = repo.releases(prerelease).await.unwrap_or_else(|_e| {
328 let version = version.unwrap_or(fallback.as_str());
330 vec![Release {
331 tag_name: tag_pattern.as_ref().map_or_else(
332 || version.to_string(),
333 |pattern| pattern.resolve_tag(version),
334 ),
335 name: String::default(),
336 prerelease,
337 commit: None,
338 published_at: String::default(),
339 }]
340 });
341
342 if let Some(pattern) = tag_pattern.as_ref() {
344 releases.retain(|r| pattern.regex.is_match(&r.tag_name));
345 }
346
347 let mut binaries: HashMap<_, _> = releases
350 .into_iter()
351 .map(|r| {
352 let version = tag_pattern
353 .as_ref()
354 .and_then(|pattern| pattern.version(&r.tag_name).map(|v| v.to_string()))
355 .unwrap_or_else(|| r.tag_name.clone());
356 (version, r.tag_name)
357 })
358 .collect();
359
360 let version = version.map(|v| {
363 tag_pattern
364 .as_ref()
365 .and_then(|pattern| pattern.version(v))
366 .unwrap_or(v)
367 .to_string()
368 });
369
370 let cached_files = read_dir(cache).into_iter().flatten();
372 let cached_file_names = cached_files
373 .filter_map(|f| f.ok().and_then(|f| f.file_name().into_string().ok()));
374 for file in cached_file_names.filter(|f| cache_filter(f)) {
375 let version = file.replace(&format!("{name}-"), "");
376 let tag = tag_pattern.as_ref().map_or_else(
377 || version.to_string(),
378 |pattern| pattern.resolve_tag(&version),
379 );
380 binaries.insert(version, tag);
381 }
382
383 let mut versions: Vec<_> = binaries.keys().cloned().collect();
385 let versions = version_comparator(versions.as_mut_slice());
386
387 let tag = version.as_ref().map_or_else(
390 || {
391 let resolved_version =
393 Binary::resolve_version(name, None, &versions, cache);
394 resolved_version.and_then(|v| binaries.get(v)).cloned()
395 },
396 |v| {
397 Some(
399 tag_pattern
400 .as_ref()
401 .map_or_else(|| v.to_string(), |pattern| pattern.resolve_tag(v)),
402 )
403 },
404 );
405
406 let latest: Option<String> = version
409 .is_none()
410 .then(|| versions.first().and_then(|v| binaries.get(v.as_str()).cloned()))
411 .flatten();
412
413 Self::ReleaseArchive {
414 owner,
415 repository,
416 tag,
417 tag_pattern,
418 prerelease,
419 version_comparator,
420 fallback,
421 archive,
422 contents,
423 latest,
424 }
425 },
426 _ => self,
427 }
428 }
429}
430
431#[derive(Clone, Debug, PartialEq)]
433pub struct ArchiveFileSpec {
434 pub name: String,
436 pub target: Option<PathBuf>,
438 pub required: bool,
440}
441
442impl ArchiveFileSpec {
443 pub fn new(name: String, target: Option<PathBuf>, required: bool) -> Self {
450 Self { name, target, required }
451 }
452}
453
454#[derive(Clone, Debug)]
459pub struct TagPattern {
460 regex: Regex,
461 pattern: String,
462}
463
464impl TagPattern {
465 pub fn new(pattern: &str) -> Self {
470 Self {
471 regex: Regex::new(&format!("^{}$", pattern.replace("{version}", "(?P<version>.+)")))
472 .expect("expected valid regex"),
473 pattern: pattern.into(),
474 }
475 }
476
477 pub fn resolve_tag(&self, value: &str) -> String {
482 if self.regex.is_match(value) {
484 return value.to_string();
485 }
486
487 self.pattern.replace("{version}", value)
488 }
489
490 pub fn version<'a>(&self, value: &'a str) -> Option<&'a str> {
495 self.regex.captures(value).and_then(|c| c.name("version").map(|v| v.as_str()))
496 }
497}
498
499impl PartialEq for TagPattern {
500 fn eq(&self, other: &Self) -> bool {
501 self.regex.as_str() == other.regex.as_str() && self.pattern == other.pattern
502 }
503}
504
505impl From<&str> for TagPattern {
506 fn from(value: &str) -> Self {
507 Self::new(value)
508 }
509}
510
511fn retry_client() -> ClientWithMiddleware {
516 #[cfg(not(test))]
517 let retry_bounds = (Duration::from_secs(2), Duration::from_secs(8));
518 #[cfg(test)]
519 let retry_bounds = (Duration::from_millis(1), Duration::from_millis(4));
520
521 let retry_policy = ExponentialBackoff::builder()
522 .retry_bounds(retry_bounds.0, retry_bounds.1)
523 .build_with_max_retries(3);
524 ClientBuilder::new(reqwest::Client::new())
525 .with(RetryTransientMiddleware::new_with_policy(retry_policy))
526 .build()
527}
528
529async fn from_archive(
536 url: &str,
537 contents: &[ArchiveFileSpec],
538 status: &impl Status,
539) -> Result<(), Error> {
540 status.update(&format!("Downloading from {url}..."));
542 let response = retry_client().get(url).send().await?.error_for_status()?;
543 let mut file = tempfile()?;
544 file.write_all(&response.bytes().await?)?;
545 file.seek(SeekFrom::Start(0))?;
546 status.update("Extracting from archive...");
548 let tar = GzDecoder::new(file);
549 let mut archive = Archive::new(tar);
550 let temp_dir = tempdir()?;
551 let working_dir = temp_dir.path();
552 archive.unpack(working_dir)?;
553 for ArchiveFileSpec { name, target, required } in contents {
554 let src = working_dir.join(name);
555 if src.exists() {
556 set_executable_permission(&src)?;
557 if let Some(target) = target &&
558 let Err(_e) = rename(&src, target)
559 {
560 copy(&src, target)?;
563 std::fs::remove_file(&src)?;
564 }
565 } else if *required {
566 return Err(Error::ArchiveError(format!(
567 "Expected file '{}' in archive, but it was not found.",
568 name
569 )));
570 }
571 }
572 status.update("Sourcing complete.");
573 Ok(())
574}
575
576#[allow(clippy::too_many_arguments)]
588async fn from_git(
589 url: &str,
590 reference: Option<&str>,
591 manifest: Option<impl AsRef<Path>>,
592 package: &str,
593 artifacts: &[(&str, impl AsRef<Path>)],
594 release: bool,
595 status: &impl Status,
596 verbose: bool,
597) -> Result<(), Error> {
598 let temp_dir = tempdir()?;
600 let working_dir = temp_dir.path();
601 status.update(&format!("Cloning {url}..."));
602 Git::clone(&Url::parse(url)?, working_dir, reference)?;
603 status.update("Starting build of binary...");
605 let manifest = manifest
606 .as_ref()
607 .map_or_else(|| working_dir.join("Cargo.toml"), |m| working_dir.join(m));
608 build(manifest, package, artifacts, release, status, verbose).await?;
609 status.update("Sourcing complete.");
610 Ok(())
611}
612
613#[allow(clippy::too_many_arguments)]
626async fn from_github_archive(
627 owner: &str,
628 repository: &str,
629 reference: Option<&str>,
630 manifest: Option<impl AsRef<Path>>,
631 package: &str,
632 artifacts: &[(&str, impl AsRef<Path>)],
633 release: bool,
634 status: &impl Status,
635 verbose: bool,
636) -> Result<(), Error> {
637 let response = match reference {
639 Some(reference) => {
640 let urls = [
642 format!(
643 "https://github.com/{owner}/{repository}/archive/refs/heads/{reference}.tar.gz"
644 ),
645 format!(
646 "https://github.com/{owner}/{repository}/archive/refs/tags/{reference}.tar.gz"
647 ),
648 format!("https://github.com/{owner}/{repository}/archive/{reference}.tar.gz"),
649 ];
650 let mut response = None;
651 for url in urls {
652 status.update(&format!("Downloading from {url}..."));
653 response = Some(GITHUB_API_CLIENT.get(url).await);
654 if let Some(Err(api::Error::HttpError(e))) = &response &&
655 e.status() == Some(StatusCode::NOT_FOUND)
656 {
657 tokio::time::sleep(Duration::from_secs(1)).await;
658 continue;
659 }
660 break;
661 }
662 response.expect("value set above")?
663 },
664 None => {
665 let url = format!("https://api.github.com/repos/{owner}/{repository}/tarball");
666 status.update(&format!("Downloading from {url}..."));
667 GITHUB_API_CLIENT.get(url).await?
668 },
669 };
670 let mut file = tempfile()?;
671 file.write_all(&response)?;
672 file.seek(SeekFrom::Start(0))?;
673 status.update("Extracting from archive...");
675 let tar = GzDecoder::new(file);
676 let mut archive = Archive::new(tar);
677 let temp_dir = tempdir()?;
678 let mut working_dir = temp_dir.path().into();
679 archive.unpack(&working_dir)?;
680 let entries: Vec<_> = read_dir(&working_dir)?.take(2).filter_map(|x| x.ok()).collect();
682 match entries.len() {
683 0 => {
684 return Err(Error::ArchiveError(
685 "The downloaded archive does not contain any entries.".into(),
686 ));
687 },
688 1 => working_dir = entries[0].path(), _ => {}, }
692 status.update("Starting build of binary...");
694 let manifest = manifest
695 .as_ref()
696 .map_or_else(|| working_dir.join("Cargo.toml"), |m| working_dir.join(m));
697 build(&manifest, package, artifacts, release, status, verbose).await?;
698 status.update("Sourcing complete.");
699 Ok(())
700}
701
702pub(crate) async fn from_local_package(
711 manifest: &Path,
712 package: &str,
713 release: bool,
714 status: &impl Status,
715 verbose: bool,
716) -> Result<(), Error> {
717 status.update("Starting build of binary...");
719 const EMPTY: [(&str, PathBuf); 0] = [];
720 build(manifest, package, &EMPTY, release, status, verbose).await?;
721 status.update("Sourcing complete.");
722 Ok(())
723}
724
725async fn from_url(url: &str, path: &Path, status: &impl Status) -> Result<(), Error> {
732 status.update(&format!("Downloading from {url}..."));
734 download(url, path).await?;
735 status.update("Sourcing complete.");
736 Ok(())
737}
738
739async fn build(
749 manifest: impl AsRef<Path>,
750 package: &str,
751 artifacts: &[(&str, impl AsRef<Path>)],
752 release: bool,
753 status: &impl Status,
754 verbose: bool,
755) -> Result<(), Error> {
756 let manifest_path = manifest.as_ref().to_str().expect("expected manifest path to be valid");
758 let mut args = vec!["build", "-p", package, "--manifest-path", manifest_path];
759 if release {
760 args.push("--release")
761 }
762 let command = cmd("cargo", args);
764 match verbose {
765 false => {
766 let reader = command.stderr_to_stdout().reader()?;
767 let output = std::io::BufReader::new(reader).lines();
768 for line in output {
769 status.update(&line?);
770 }
771 },
772 true => {
773 command.run()?;
774 },
775 }
776 let target = manifest
778 .as_ref()
779 .parent()
780 .expect("expected parent directory to be valid")
781 .join(format!("target/{}", if release { "release" } else { "debug" }));
782 for (name, dest) in artifacts {
783 copy(target.join(name), dest)?;
784 }
785 Ok(())
786}
787
788async fn download(url: &str, dest: &Path) -> Result<(), Error> {
794 let response = retry_client().get(url).send().await?.error_for_status()?;
796 let mut file = File::create(dest)?;
797 file.write_all(&response.bytes().await?)?;
798 set_executable_permission(dest)?;
800 Ok(())
801}
802
803pub fn set_executable_permission<P: AsRef<Path>>(path: P) -> Result<(), Error> {
808 let mut perms = metadata(&path)?.permissions();
809 perms.set_mode(0o755);
810 std::fs::set_permissions(path, perms)?;
811 Ok(())
812}
813
814#[cfg(test)]
815pub(super) mod tests {
816 use super::{GitHub::*, Status, *};
817 use crate::{polkadot_sdk::parse_version, target};
818 use tempfile::tempdir;
819
820 #[tokio::test]
821 async fn sourcing_from_archive_works() -> anyhow::Result<()> {
822 let url = "https://github.com/r0gue-io/polkadot/releases/latest/download/polkadot-aarch64-apple-darwin.tar.gz".to_string();
823 let name = "polkadot".to_string();
824 let contents =
825 vec![name.clone(), "polkadot-execute-worker".into(), "polkadot-prepare-worker".into()];
826 let temp_dir = tempdir()?;
827
828 Source::Archive { url, contents: contents.clone() }
829 .source(temp_dir.path(), true, &Output, true)
830 .await?;
831 for item in contents {
832 assert!(temp_dir.path().join(item).exists());
833 }
834 Ok(())
835 }
836
837 #[tokio::test]
838 async fn resolve_from_archive_is_noop() -> anyhow::Result<()> {
839 let url = "https://github.com/r0gue-io/polkadot/releases/latest/download/polkadot-aarch64-apple-darwin.tar.gz".to_string();
840 let name = "polkadot".to_string();
841 let contents =
842 vec![name.clone(), "polkadot-execute-worker".into(), "polkadot-prepare-worker".into()];
843 let temp_dir = tempdir()?;
844
845 let source = Source::Archive { url, contents: contents.clone() };
846 assert_eq!(
847 source.clone().resolve(&name, None, temp_dir.path(), filters::polkadot).await,
848 source
849 );
850 Ok(())
851 }
852
853 #[tokio::test]
854 async fn sourcing_from_git_works() -> anyhow::Result<()> {
855 crate::command_mock::CommandMock::default()
856 .execute(async || {
857 let url = Url::parse("https://github.com/hpaluch/rust-hello-world")?;
858 let package = "hello_world".to_string();
859 let temp_dir = tempdir()?;
860
861 Source::Git {
862 url,
863 reference: None,
864 manifest: None,
865 package: package.clone(),
866 artifacts: vec![package.clone()],
867 }
868 .source(temp_dir.path(), true, &Output, true)
869 .await?;
870 assert!(temp_dir.path().join(package).exists());
871 Ok(())
872 })
873 .await
874 }
875
876 #[tokio::test]
877 async fn resolve_from_git_is_noop() -> anyhow::Result<()> {
878 let url = Url::parse("https://github.com/hpaluch/rust-hello-world")?;
879 let package = "hello_world".to_string();
880 let temp_dir = tempdir()?;
881
882 let source = Source::Git {
883 url,
884 reference: None,
885 manifest: None,
886 package: package.clone(),
887 artifacts: vec![package.clone()],
888 };
889 assert_eq!(
890 source
891 .clone()
892 .resolve(&package, None, temp_dir.path(), |f| filters::prefix(f, &package))
893 .await,
894 source
895 );
896 Ok(())
897 }
898
899 #[tokio::test]
900 async fn sourcing_from_git_ref_works() -> anyhow::Result<()> {
901 crate::command_mock::CommandMock::default()
902 .execute(async || {
903 let url = Url::parse("https://github.com/hpaluch/rust-hello-world")?;
904 let initial_commit = "436b7dbffdfaaf7ad90bf44ae8fdcb17eeee65a3".to_string();
905 let package = "hello_world".to_string();
906 let temp_dir = tempdir()?;
907
908 Source::Git {
909 url,
910 reference: Some(initial_commit.clone()),
911 manifest: None,
912 package: package.clone(),
913 artifacts: vec![package.clone()],
914 }
915 .source(temp_dir.path(), true, &Output, true)
916 .await?;
917 assert!(temp_dir.path().join(format!("{package}-{initial_commit}")).exists());
918 Ok(())
919 })
920 .await
921 }
922
923 #[tokio::test]
924 async fn sourcing_from_github_release_archive_works() -> anyhow::Result<()> {
925 let owner = "r0gue-io".to_string();
926 let repository = "polkadot".to_string();
927 let version = "stable2512";
928 let tag_pattern = Some("polkadot-{version}".into());
929 let fallback = "stable2512".into();
930 let archive = format!("polkadot-{}.tar.gz", target()?);
931 let contents = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"];
932 let temp_dir = tempdir()?;
933
934 Source::GitHub(ReleaseArchive {
935 owner,
936 repository,
937 tag: Some(format!("polkadot-{version}")),
938 tag_pattern,
939 prerelease: false,
940 version_comparator,
941 fallback,
942 archive,
943 contents: contents.map(|n| ArchiveFileSpec::new(n.into(), None, true)).to_vec(),
944 latest: None,
945 })
946 .source(temp_dir.path(), true, &Output, true)
947 .await?;
948 for item in contents {
949 assert!(temp_dir.path().join(format!("{item}-{version}")).exists());
950 }
951 Ok(())
952 }
953
954 #[tokio::test]
955 async fn resolve_from_github_release_archive_works() -> anyhow::Result<()> {
956 crate::command_mock::CommandMock::default()
957 .execute(async || {
958 let owner = "r0gue-io".to_string();
959 let repository = "polkadot".to_string();
960 let version = "stable2512";
961 let tag_pattern = Some("polkadot-{version}".into());
962 let fallback = "stable2512".into();
963 let archive = format!("polkadot-{}.tar.gz", target()?);
964 let contents = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"];
965 let temp_dir = tempdir()?;
966
967 let mut releases: Vec<_> = crate::GitHub::new(owner.as_str(), repository.as_str())
969 .releases(false)
970 .await?
971 .into_iter()
972 .map(|r| r.tag_name)
973 .collect();
974 let sorted_releases = version_comparator(releases.as_mut_slice());
975
976 let source = Source::GitHub(ReleaseArchive {
977 owner,
978 repository,
979 tag: None,
980 tag_pattern,
981 prerelease: false,
982 version_comparator,
983 fallback,
984 archive,
985 contents: contents.map(|n| ArchiveFileSpec::new(n.into(), None, true)).to_vec(),
986 latest: None,
987 });
988
989 for version in [Some(version), None] {
991 let source = source
992 .clone()
993 .resolve("polkadot", version, temp_dir.path(), filters::polkadot)
994 .await;
995 let expected_tag = version.map_or_else(
996 || sorted_releases.0.first().unwrap().into(),
997 |v| format!("polkadot-{v}"),
998 );
999 let expected_latest =
1000 version.map_or_else(|| sorted_releases.0.first(), |_| None);
1001 assert!(matches!(
1002 source,
1003 Source::GitHub(ReleaseArchive { tag, latest, .. } )
1004 if tag == Some(expected_tag) && latest.as_ref() == expected_latest
1005 ));
1006 }
1007
1008 let cached_version = "polkadot-stable2612";
1010 File::create(temp_dir.path().join(cached_version))?;
1011 for version in [Some(version), None] {
1012 let source = source
1013 .clone()
1014 .resolve("polkadot", version, temp_dir.path(), filters::polkadot)
1015 .await;
1016 let expected_tag = version
1017 .map_or_else(|| cached_version.to_string(), |v| format!("polkadot-{v}"));
1018 let expected_latest =
1019 version.map_or_else(|| Some(cached_version.to_string()), |_| None);
1020 assert!(matches!(
1021 source,
1022 Source::GitHub(ReleaseArchive { tag, latest, .. } )
1023 if tag == Some(expected_tag) && latest == expected_latest
1024 ));
1025 }
1026
1027 Ok(())
1028 })
1029 .await
1030 }
1031
1032 #[tokio::test]
1033 async fn sourcing_from_github_release_archive_maps_contents() -> anyhow::Result<()> {
1034 let owner = "r0gue-io".to_string();
1035 let repository = "polkadot".to_string();
1036 let version = "stable2512";
1037 let tag_pattern = Some("polkadot-{version}".into());
1038 let name = "polkadot".to_string();
1039 let fallback = "stable2512".into();
1040 let archive = format!("{name}-{}.tar.gz", target()?);
1041 let contents = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"];
1042 let temp_dir = tempdir()?;
1043 let prefix = "test";
1044
1045 Source::GitHub(ReleaseArchive {
1046 owner,
1047 repository,
1048 tag: Some(format!("polkadot-{version}")),
1049 tag_pattern,
1050 prerelease: false,
1051 version_comparator,
1052 fallback,
1053 archive,
1054 contents: contents
1055 .map(|n| ArchiveFileSpec::new(n.into(), Some(format!("{prefix}-{n}").into()), true))
1056 .to_vec(),
1057 latest: None,
1058 })
1059 .source(temp_dir.path(), true, &Output, true)
1060 .await?;
1061 for item in contents {
1062 assert!(temp_dir.path().join(format!("{prefix}-{item}-{version}")).exists());
1063 }
1064 Ok(())
1065 }
1066
1067 #[tokio::test]
1068 async fn sourcing_from_latest_github_release_archive_works() -> anyhow::Result<()> {
1069 let owner = "r0gue-io".to_string();
1070 let repository = "polkadot".to_string();
1071 let tag_pattern = Some("polkadot-{version}".into());
1072 let name = "polkadot".to_string();
1073 let fallback = "stable2512".into();
1074 let archive = format!("{name}-{}.tar.gz", target()?);
1075 let contents = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"];
1076 let temp_dir = tempdir()?;
1077
1078 Source::GitHub(ReleaseArchive {
1079 owner,
1080 repository,
1081 tag: None,
1082 tag_pattern,
1083 prerelease: false,
1084 version_comparator,
1085 fallback,
1086 archive,
1087 contents: contents.map(|n| ArchiveFileSpec::new(n.into(), None, true)).to_vec(),
1088 latest: None,
1089 })
1090 .source(temp_dir.path(), true, &Output, true)
1091 .await?;
1092 for item in contents {
1093 assert!(temp_dir.path().join(item).exists());
1094 }
1095 Ok(())
1096 }
1097
1098 #[tokio::test]
1099 async fn sourcing_from_github_source_code_archive_works() -> anyhow::Result<()> {
1100 crate::command_mock::CommandMock::default()
1101 .execute(async || {
1102 let owner = "paritytech".to_string();
1103 let repository = "polkadot-sdk".to_string();
1104 let package = "polkadot".to_string();
1105 let temp_dir = tempdir()?;
1106 let initial_commit = "72dba98250a6267c61772cd55f8caf193141050f";
1107 let manifest = PathBuf::from("substrate/Cargo.toml");
1108
1109 Source::GitHub(SourceCodeArchive {
1110 owner,
1111 repository,
1112 reference: Some(initial_commit.to_string()),
1113 manifest: Some(manifest),
1114 package: package.clone(),
1115 artifacts: vec![package.clone()],
1116 })
1117 .source(temp_dir.path(), true, &Output, true)
1118 .await?;
1119 assert!(temp_dir.path().join(format!("{package}-{initial_commit}")).exists());
1120 Ok(())
1121 })
1122 .await
1123 }
1124
1125 #[tokio::test]
1126 async fn resolve_from_github_source_code_archive_is_noop() -> anyhow::Result<()> {
1127 let owner = "paritytech".to_string();
1128 let repository = "polkadot-sdk".to_string();
1129 let package = "polkadot".to_string();
1130 let temp_dir = tempdir()?;
1131 let initial_commit = "72dba98250a6267c61772cd55f8caf193141050f";
1132 let manifest = PathBuf::from("substrate/Cargo.toml");
1133
1134 let source = Source::GitHub(SourceCodeArchive {
1135 owner,
1136 repository,
1137 reference: Some(initial_commit.to_string()),
1138 manifest: Some(manifest),
1139 package: package.clone(),
1140 artifacts: vec![package.clone()],
1141 });
1142 assert_eq!(
1143 source.clone().resolve(&package, None, temp_dir.path(), filters::polkadot).await,
1144 source
1145 );
1146 Ok(())
1147 }
1148
1149 #[tokio::test]
1150 async fn sourcing_from_latest_github_source_code_archive_works() -> anyhow::Result<()> {
1151 crate::command_mock::CommandMock::default()
1152 .execute(async || {
1153 let owner = "hpaluch".to_string();
1154 let repository = "rust-hello-world".to_string();
1155 let package = "hello_world".to_string();
1156 let temp_dir = tempdir()?;
1157
1158 Source::GitHub(SourceCodeArchive {
1159 owner,
1160 repository,
1161 reference: None,
1162 manifest: None,
1163 package: package.clone(),
1164 artifacts: vec![package.clone()],
1165 })
1166 .source(temp_dir.path(), true, &Output, true)
1167 .await?;
1168 assert!(temp_dir.path().join(package).exists());
1169 Ok(())
1170 })
1171 .await
1172 }
1173
1174 #[tokio::test]
1175 async fn sourcing_from_url_works() -> anyhow::Result<()> {
1176 let url =
1177 "https://github.com/paritytech/polkadot-sdk/releases/latest/download/polkadot.asc"
1178 .to_string();
1179 let name = "polkadot";
1180 let temp_dir = tempdir()?;
1181
1182 Source::Url { url, name: name.into() }
1183 .source(temp_dir.path(), false, &Output, true)
1184 .await?;
1185 assert!(temp_dir.path().join(name).exists());
1186 Ok(())
1187 }
1188
1189 #[tokio::test]
1190 async fn resolve_from_url_is_noop() -> anyhow::Result<()> {
1191 let url =
1192 "https://github.com/paritytech/polkadot-sdk/releases/latest/download/polkadot.asc"
1193 .to_string();
1194 let name = "polkadot";
1195 let temp_dir = tempdir()?;
1196
1197 let source = Source::Url { url, name: name.into() };
1198 assert_eq!(
1199 source.clone().resolve(name, None, temp_dir.path(), filters::polkadot).await,
1200 source
1201 );
1202 Ok(())
1203 }
1204
1205 #[tokio::test]
1206 async fn from_archive_works() -> anyhow::Result<()> {
1207 let temp_dir = tempdir()?;
1208 let url = "https://github.com/r0gue-io/polkadot/releases/latest/download/polkadot-aarch64-apple-darwin.tar.gz";
1209 let contents: Vec<_> = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"]
1210 .into_iter()
1211 .map(|b| ArchiveFileSpec::new(b.into(), Some(temp_dir.path().join(b)), true))
1212 .collect();
1213
1214 from_archive(url, &contents, &Output).await?;
1215 for ArchiveFileSpec { target, .. } in contents {
1216 assert!(target.unwrap().exists());
1217 }
1218 Ok(())
1219 }
1220
1221 #[tokio::test]
1222 async fn from_git_works() -> anyhow::Result<()> {
1223 let url = "https://github.com/hpaluch/rust-hello-world";
1224 let package = "hello_world";
1225 let initial_commit = "436b7dbffdfaaf7ad90bf44ae8fdcb17eeee65a3";
1226 let temp_dir = tempdir()?;
1227 let path = temp_dir.path().join(package);
1228
1229 from_git(
1230 url,
1231 Some(initial_commit),
1232 None::<&Path>,
1233 package,
1234 &[(package, &path)],
1235 true,
1236 &Output,
1237 false,
1238 )
1239 .await?;
1240 assert!(path.exists());
1241 Ok(())
1242 }
1243
1244 #[tokio::test]
1245 async fn from_github_archive_works() -> anyhow::Result<()> {
1246 crate::command_mock::CommandMock::default()
1247 .execute(async || {
1248 let owner = "paritytech";
1249 let repository = "polkadot-sdk";
1250 let package = "polkadot";
1251 let temp_dir = tempdir()?;
1252 let path = temp_dir.path().join(package);
1253 let initial_commit = "72dba98250a6267c61772cd55f8caf193141050f";
1254 let manifest = "substrate/Cargo.toml";
1255
1256 from_github_archive(
1257 owner,
1258 repository,
1259 Some(initial_commit),
1260 Some(manifest),
1261 package,
1262 &[(package, &path)],
1263 true,
1264 &Output,
1265 true,
1266 )
1267 .await?;
1268 assert!(path.exists());
1269 Ok(())
1270 })
1271 .await
1272 }
1273
1274 #[tokio::test]
1275 async fn from_latest_github_archive_works() -> anyhow::Result<()> {
1276 crate::command_mock::CommandMock::default()
1277 .execute(async || {
1278 let owner = "hpaluch";
1279 let repository = "rust-hello-world";
1280 let package = "hello_world";
1281 let temp_dir = tempdir()?;
1282 let path = temp_dir.path().join(package);
1283
1284 from_github_archive(
1285 owner,
1286 repository,
1287 None,
1288 None::<&Path>,
1289 package,
1290 &[(package, &path)],
1291 true,
1292 &Output,
1293 true,
1294 )
1295 .await?;
1296 assert!(path.exists());
1297 Ok(())
1298 })
1299 .await
1300 }
1301
1302 #[tokio::test]
1303 async fn from_local_package_works() -> anyhow::Result<()> {
1304 crate::command_mock::CommandMock::default()
1305 .execute(async || {
1306 let temp_dir = tempdir()?;
1307 let name = "hello_world";
1308 cmd("cargo", ["new", name, "--bin"]).dir(temp_dir.path()).run()?;
1309 let manifest = temp_dir.path().join(name).join("Cargo.toml");
1310
1311 from_local_package(&manifest, name, false, &Output, true).await?;
1312 assert!(manifest.parent().unwrap().join("target/debug").join(name).exists());
1313 Ok(())
1314 })
1315 .await
1316 }
1317
1318 #[tokio::test]
1319 async fn from_url_works() -> anyhow::Result<()> {
1320 let url =
1321 "https://github.com/paritytech/polkadot-sdk/releases/latest/download/polkadot.asc";
1322 let temp_dir = tempdir()?;
1323 let path = temp_dir.path().join("polkadot");
1324
1325 from_url(url, &path, &Output).await?;
1326 assert!(path.exists());
1327 assert_ne!(metadata(path)?.permissions().mode() & 0o755, 0);
1328 Ok(())
1329 }
1330
1331 #[test]
1332 fn tag_pattern_works() {
1333 let pattern: TagPattern = "polkadot-{version}".into();
1334 assert_eq!(pattern.regex.as_str(), "^polkadot-(?P<version>.+)$");
1335 assert_eq!(pattern.pattern, "polkadot-{version}");
1336 assert_eq!(pattern, pattern.clone());
1337
1338 for value in ["polkadot-stable2512", "stable2512"] {
1339 assert_eq!(pattern.resolve_tag(value).as_str(), "polkadot-stable2512");
1340 }
1341 assert_eq!(pattern.version("polkadot-stable2512"), Some("stable2512"));
1342 }
1343
1344 fn version_comparator<T: AsRef<str> + Ord>(versions: &'_ mut [T]) -> SortedSlice<'_, T> {
1345 SortedSlice::by(versions, |a, b| parse_version(b.as_ref()).cmp(&parse_version(a.as_ref())))
1346 }
1347
1348 pub(crate) struct Output;
1349 impl Status for Output {
1350 fn update(&self, status: &str) {
1351 println!("{status}")
1352 }
1353 }
1354
1355 mod retry {
1356 use super::*;
1357 use mockito::{Mock, Server};
1358
1359 async fn mock_status(server: &mut Server, code: u16) -> Mock {
1360 server.mock("GET", "/test").with_status(code as usize).create_async().await
1361 }
1362
1363 #[tokio::test]
1364 async fn retry_client_succeeds_on_first_attempt() {
1365 let mut server = Server::new_async().await;
1366 let mock = mock_status(&mut server, 200).await;
1367
1368 let url = format!("{}/test", server.url());
1369 let response = retry_client().get(&url).send().await.unwrap();
1370 assert_eq!(response.status(), 200);
1371 mock.assert_async().await;
1372 }
1373
1374 #[tokio::test]
1375 async fn retry_client_retries_on_503_then_succeeds() {
1376 let mut server = Server::new_async().await;
1377 let fail_mock =
1378 server.mock("GET", "/test").with_status(503).expect(1).create_async().await;
1379 let success_mock =
1380 server.mock("GET", "/test").with_status(200).expect(1).create_async().await;
1381
1382 let url = format!("{}/test", server.url());
1383 let response = retry_client().get(&url).send().await.unwrap();
1384 assert_eq!(response.status(), 200);
1385 fail_mock.assert_async().await;
1386 success_mock.assert_async().await;
1387 }
1388
1389 #[tokio::test]
1390 async fn retry_client_fails_after_max_retries() {
1391 let mut server = Server::new_async().await;
1392 let mock = server.mock("GET", "/test").with_status(500).expect(4).create_async().await;
1394
1395 let url = format!("{}/test", server.url());
1396 let response = retry_client().get(&url).send().await.unwrap();
1397 assert!(response.error_for_status().is_err());
1398 mock.assert_async().await;
1399 }
1400
1401 #[tokio::test]
1402 async fn retry_client_does_not_retry_on_404() {
1403 let mut server = Server::new_async().await;
1404 let mock = server.mock("GET", "/test").with_status(404).expect(1).create_async().await;
1405
1406 let url = format!("{}/test", server.url());
1407 let response = retry_client().get(&url).send().await.unwrap();
1408 assert!(response.error_for_status().is_err());
1409 mock.assert_async().await;
1410 }
1411 }
1412}
1413
1414pub mod traits {
1416 pub trait Source {
1418 type Error;
1420
1421 fn source(&self) -> Result<super::Source, Self::Error>;
1423 }
1424
1425 pub mod enums {
1427 use strum::EnumProperty;
1428
1429 pub trait Source {
1431 fn binary(&self) -> &'static str;
1433
1434 fn fallback(&self) -> &str;
1436
1437 fn prerelease(&self) -> Option<bool>;
1439 }
1440
1441 pub trait Repository: Source {
1443 fn repository(&self) -> &str;
1445
1446 fn tag_pattern(&self) -> Option<&str>;
1449 }
1450
1451 impl<T: EnumProperty> Source for T {
1452 fn binary(&self) -> &'static str {
1453 self.get_str("Binary").expect("expected specification of `Binary` name")
1454 }
1455
1456 fn fallback(&self) -> &str {
1457 self.get_str("Fallback")
1458 .expect("expected specification of `Fallback` release tag")
1459 }
1460
1461 fn prerelease(&self) -> Option<bool> {
1462 self.get_str("Prerelease").map(|v| {
1463 v.parse().expect("expected parachain prerelease value to be true/false")
1464 })
1465 }
1466 }
1467
1468 impl<T: EnumProperty> Repository for T {
1469 fn repository(&self) -> &str {
1470 self.get_str("Repository").expect("expected specification of `Repository` url")
1471 }
1472
1473 fn tag_pattern(&self) -> Option<&str> {
1474 self.get_str("TagPattern")
1475 }
1476 }
1477 }
1478
1479 #[cfg(test)]
1480 mod tests {
1481 use super::enums::{Repository, Source};
1482 use strum_macros::{EnumProperty, VariantArray};
1483
1484 #[derive(EnumProperty, VariantArray)]
1485 pub(super) enum Chain {
1486 #[strum(props(
1487 Repository = "https://github.com/paritytech/polkadot-sdk",
1488 Binary = "polkadot",
1489 Prerelease = "false",
1490 Fallback = "v1.12.0",
1491 TagPattern = "polkadot-{version}"
1492 ))]
1493 Polkadot,
1494 #[strum(props(Repository = "https://github.com/r0gue-io/fallback", Fallback = "v1.0"))]
1495 Fallback,
1496 }
1497
1498 #[test]
1499 fn binary_works() {
1500 assert_eq!("polkadot", Chain::Polkadot.binary())
1501 }
1502
1503 #[test]
1504 fn fallback_works() {
1505 assert_eq!("v1.12.0", Chain::Polkadot.fallback())
1506 }
1507
1508 #[test]
1509 fn prerelease_works() {
1510 assert!(!Chain::Polkadot.prerelease().unwrap())
1511 }
1512
1513 #[test]
1514 fn repository_works() {
1515 assert_eq!("https://github.com/paritytech/polkadot-sdk", Chain::Polkadot.repository())
1516 }
1517
1518 #[test]
1519 fn tag_pattern_works() {
1520 assert_eq!("polkadot-{version}", Chain::Polkadot.tag_pattern().unwrap())
1521 }
1522 }
1523}
1524
1525pub mod filters {
1527 pub fn prefix(candidate: &str, prefix: &str) -> bool {
1533 candidate.starts_with(prefix) &&
1534 (prefix != "polkadot" ||
1536 !["polkadot-execute-worker", "polkadot-prepare-worker", "polkadot-parachain", "polkadot-omni-node"]
1537 .iter()
1538 .any(|i| candidate.starts_with(i)))
1539 }
1540
1541 #[cfg(test)]
1542 pub(crate) fn polkadot(file: &str) -> bool {
1543 prefix(file, "polkadot")
1544 }
1545
1546 #[cfg(test)]
1547 mod tests {
1548 use super::*;
1549
1550 #[test]
1551 fn prefix_filter_excludes_polkadot_variants() {
1552 assert!(prefix("polkadot", "polkadot"));
1554 assert!(prefix("polkadot-stable2512", "polkadot"));
1555 assert!(prefix("polkadot-stable2512-1", "polkadot"));
1556
1557 assert!(!prefix("polkadot-execute-worker", "polkadot"));
1559 assert!(!prefix("polkadot-execute-worker-stable2512", "polkadot"));
1560 assert!(!prefix("polkadot-prepare-worker", "polkadot"));
1561 assert!(!prefix("polkadot-prepare-worker-stable2512-1", "polkadot"));
1562 assert!(!prefix("polkadot-parachain", "polkadot"));
1563 assert!(!prefix("polkadot-parachain-stable2512", "polkadot"));
1564 assert!(!prefix("polkadot-omni-node", "polkadot"));
1565 assert!(!prefix("polkadot-omni-node-stable2512-1", "polkadot"));
1566
1567 assert!(prefix("polkadot-parachain", "polkadot-parachain"));
1569 assert!(prefix("polkadot-omni-node", "polkadot-omni-node"));
1570 }
1571 }
1572}