Skip to main content

pop_common/sourcing/
mod.rs

1// SPDX-License-Identifier: GPL-3.0
2
3use 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/// An error relating to the sourcing of binaries.
29#[derive(Error, Debug)]
30pub enum Error {
31	/// An error occurred.
32	#[error("Anyhow error: {0}")]
33	AnyhowError(#[from] anyhow::Error),
34	/// An API error occurred.
35	#[error("API error: {0}")]
36	ApiError(#[from] api::Error),
37	/// An error occurred sourcing a binary from an archive.
38	#[error("Archive error: {0}")]
39	ArchiveError(String),
40	/// A HTTP error occurred.
41	#[error("HTTP error: {0} caused by {:?}", reqwest::Error::source(.0))]
42	HttpError(#[from] reqwest::Error),
43	/// A HTTP middleware error occurred.
44	#[error("HTTP middleware error: {0}")]
45	MiddlewareError(#[from] reqwest_middleware::Error),
46	/// An IO error occurred.
47	#[error("IO error: {0}")]
48	IO(#[from] std::io::Error),
49	/// A binary cannot be sourced.
50	#[error("Missing binary: {0}")]
51	MissingBinary(String),
52	/// An error occurred during parsing.
53	#[error("ParseError error: {0}")]
54	ParseError(#[from] url::ParseError),
55}
56
57/// The source of a binary.
58#[derive(Clone, Debug, PartialEq)]
59pub enum Source {
60	/// An archive for download.
61	#[allow(dead_code)]
62	Archive {
63		/// The url of the archive.
64		url: String,
65		/// The archive contents required, including the binary name.
66		contents: Vec<String>,
67	},
68	/// A git repository.
69	Git {
70		/// The url of the repository.
71		url: Url,
72		/// If applicable, the branch, tag or commit.
73		reference: Option<String>,
74		/// If applicable, a specification of the path to the manifest.
75		manifest: Option<PathBuf>,
76		/// The name of the package to be built.
77		package: String,
78		/// Any additional build artifacts that are required.
79		artifacts: Vec<String>,
80	},
81	/// A GitHub repository.
82	GitHub(GitHub),
83	/// A URL for download.
84	#[allow(dead_code)]
85	Url {
86		/// The URL for download.
87		url: String,
88		/// The name of the binary.
89		name: String,
90	},
91}
92
93impl Source {
94	/// Sources the binary.
95	///
96	/// # Arguments
97	/// * `cache` - the cache to be used.
98	/// * `release` - whether any binaries needing to be built should be done so using the release
99	///   profile.
100	/// * `status` - used to observe status updates.
101	/// * `verbose` - whether verbose output is required.
102	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	/// Performs any additional processing required to resolve the binary from a source.
144	///
145	/// Determines whether the binary already exists locally, using the latest version available,
146	/// and whether there are any newer versions available
147	///
148	/// # Arguments
149	/// * `name` - the name of the binary.
150	/// * `version` - a specific version of the binary required.
151	/// * `cache` - the cache being used.
152	/// * `cache_filter` - a filter to be used to determine whether a cached binary is eligible.
153	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/// A binary sourced from GitHub.
169#[derive(Clone, Debug, Derivative)]
170#[derivative(PartialEq)]
171pub enum GitHub {
172	/// An archive for download from a GitHub release.
173	ReleaseArchive {
174		/// The owner of the repository - i.e. <https://github.com/{owner}/repository>.
175		owner: String,
176		/// The name of the repository - i.e. <https://github.com/owner/{repository}>.
177		repository: String,
178		/// The release tag to be used, where `None` is latest.
179		tag: Option<String>,
180		/// If applicable, a pattern to be used to determine applicable releases along with
181		/// determining subcomponents from a release tag - e.g. `polkadot-{version}`.
182		tag_pattern: Option<TagPattern>,
183		/// Whether pre-releases are to be used.
184		prerelease: bool,
185		/// A function that orders candidates for selection when multiple versions are available.
186		#[derivative(PartialEq = "ignore")]
187		version_comparator: for<'a> fn(&'a mut [String]) -> SortedSlice<'a, String>,
188		/// The version to use if an appropriate version cannot be resolved.
189		fallback: String,
190		/// The name of the archive (asset) to download.
191		archive: String,
192		/// The archive contents required.
193		contents: Vec<ArchiveFileSpec>,
194		/// If applicable, the latest release tag available.
195		latest: Option<String>,
196	},
197	/// A source code archive for download from GitHub.
198	SourceCodeArchive {
199		/// The owner of the repository - i.e. <https://github.com/{owner}/repository>.
200		owner: String,
201		/// The name of the repository - i.e. <https://github.com/owner/{repository}>.
202		repository: String,
203		/// If applicable, the branch, tag or commit.
204		reference: Option<String>,
205		/// If applicable, a specification of the path to the manifest.
206		manifest: Option<PathBuf>,
207		/// The name of the package to be built.
208		package: String,
209		/// Any additional artifacts that are required.
210		artifacts: Vec<String>,
211	},
212}
213
214impl GitHub {
215	/// Sources the binary.
216	///
217	/// # Arguments
218	///
219	/// * `cache` - the cache to be used.
220	/// * `release` - whether any binaries needing to be built should be done so using the release
221	///   profile.
222	/// * `status` - used to observe status updates.
223	/// * `verbose` - whether verbose output is required.
224	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				// Complete url and contents based on the tag
235				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	/// Performs any additional processing required to resolve the binary from a source.
296	///
297	/// Determines whether the binary already exists locally, using the latest version available,
298	/// and whether there are any newer versions available
299	///
300	/// # Arguments
301	/// * `name` - the name of the binary.
302	/// * `version` - a specific version of the binary required.
303	/// * `cache` - the cache being used.
304	/// * `cache_filter` - a filter to be used to determine whether a cached binary is eligible.
305	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				// Get releases, defaulting to the specified fallback version if there's an error.
326				let repo = crate::GitHub::new(owner.as_str(), repository.as_str());
327				let mut releases = repo.releases(prerelease).await.unwrap_or_else(|_e| {
328					// Use any specified version or fall back to the last known version.
329					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				// Filter releases if a tag pattern specified
343				if let Some(pattern) = tag_pattern.as_ref() {
344					releases.retain(|r| pattern.regex.is_match(&r.tag_name));
345				}
346
347				// Select versions from release tags, used for resolving the candidate versions and
348				// local binary versioning.
349				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				// Resolve any specified version - i.e., the version could be provided as a concrete
361				// version or just a tag.
362				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				// Extract versions from any cached binaries - e.g., offline or rate-limited.
371				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				// Prepare for version resolution by sorting by configured version comparator.
384				let mut versions: Vec<_> = binaries.keys().cloned().collect();
385				let versions = version_comparator(versions.as_mut_slice());
386
387				// Define the tag to be used as either a specified version or the latest available
388				// locally.
389				let tag = version.as_ref().map_or_else(
390					|| {
391						// Resolve the version to be used.
392						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						// Ensure any specified version is a tag.
398						Some(
399							tag_pattern
400								.as_ref()
401								.map_or_else(|| v.to_string(), |pattern| pattern.resolve_tag(v)),
402						)
403					},
404				);
405
406				// // Default to the latest version when no specific version is provided by the
407				// caller.
408				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/// A specification of a file within an archive.
432#[derive(Clone, Debug, PartialEq)]
433pub struct ArchiveFileSpec {
434	/// The name of the file within the archive.
435	pub name: String,
436	/// An optional file name to be used for the file once extracted.
437	pub target: Option<PathBuf>,
438	/// Whether the file is required.
439	pub required: bool,
440}
441
442impl ArchiveFileSpec {
443	/// A specification of a file within an archive.
444	///
445	/// # Arguments
446	/// * `name` - The name of the file within the archive.
447	/// * `target` - An optional file name to be used for the file once extracted.
448	/// * `required` - Whether the file is required.
449	pub fn new(name: String, target: Option<PathBuf>, required: bool) -> Self {
450		Self { name, target, required }
451	}
452}
453
454/// A pattern used to determine captures from a release tag.
455///
456/// Only `{version}` is currently supported, used to determine a version from a release tag.
457/// Examples: `polkadot-{version}`, `node-{version}`.
458#[derive(Clone, Debug)]
459pub struct TagPattern {
460	regex: Regex,
461	pattern: String,
462}
463
464impl TagPattern {
465	/// A new pattern used to determine captures from a release tag.
466	///
467	/// # Arguments
468	/// * `pattern` - the pattern to be used.
469	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	/// Resolves a tag for the specified value.
478	///
479	/// # Arguments
480	/// * `value` - the value to resolve into a tag using the inner tag pattern.
481	pub fn resolve_tag(&self, value: &str) -> String {
482		// If input already in expected tag format, return as-is.
483		if self.regex.is_match(value) {
484			return value.to_string();
485		}
486
487		self.pattern.replace("{version}", value)
488	}
489
490	/// Extracts a version from the specified value.
491	///
492	/// # Arguments
493	/// * `value` - the value to parse.
494	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
511/// Creates an HTTP client with retry middleware using exponential backoff.
512///
513/// Retries up to 3 times on transient errors (5xx, 408, 429, and connection failures).
514/// Non-retryable errors (e.g. 404) fail immediately.
515fn 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
529/// Source binary by downloading and extracting from an archive.
530///
531/// # Arguments
532/// * `url` - The url of the archive.
533/// * `contents` - The contents within the archive which are required.
534/// * `status` - Used to observe status updates.
535async fn from_archive(
536	url: &str,
537	contents: &[ArchiveFileSpec],
538	status: &impl Status,
539) -> Result<(), Error> {
540	// Download archive
541	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	// Extract contents
547	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				// If rename fails (e.g., due to cross-device linking), fallback to copy and
561				// remove
562				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/// Source binary by cloning a git repository and then building.
577///
578/// # Arguments
579/// * `url` - The url of the repository.
580/// * `reference` - If applicable, the branch, tag or commit.
581/// * `manifest` - If applicable, a specification of the path to the manifest.
582/// * `package` - The name of the package to be built.
583/// * `artifacts` - Any additional artifacts that are required.
584/// * `release` - Whether to build optimized artifacts using the release profile.
585/// * `status` - Used to observe status updates.
586/// * `verbose` - Whether verbose output is required.
587#[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	// Clone repository into working directory
599	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	// Build binaries
604	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/// Source binary by downloading from a source code archive and then building.
614///
615/// # Arguments
616/// * `owner` - The owner of the repository.
617/// * `repository` - The name of the repository.
618/// * `reference` - If applicable, the branch, tag or commit.
619/// * `manifest` - If applicable, a specification of the path to the manifest.
620/// * `package` - The name of the package to be built.
621/// * `artifacts` - Any additional artifacts that are required.
622/// * `release` - Whether to build optimized artifacts using the release profile.
623/// * `status` - Used to observe status updates.
624/// * `verbose` - Whether verbose output is required.
625#[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	// User agent required when using GitHub API
638	let response = match reference {
639		Some(reference) => {
640			// Various potential urls to try based on not knowing the type of ref
641			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	// Extract contents
674	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	// Prepare archive contents for build
681	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(), // Automatically switch to top level directory
689		_ => {},                              /* Assume that downloaded archive does not have a
690		                                        * top level directory */
691	}
692	// Build binaries
693	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
702/// Source binary by building a local package.
703///
704/// # Arguments
705/// * `manifest` - The path to the local package manifest.
706/// * `package` - The name of the package to be built.
707/// * `release` - Whether to build optimized artifacts using the release profile.
708/// * `status` - Used to observe status updates.
709/// * `verbose` - Whether verbose output is required.
710pub(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	// Build binaries
718	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
725/// Source binary by downloading from a URL.
726///
727/// # Arguments
728/// * `url` - The url of the binary.
729/// * `path` - The (local) destination path.
730/// * `status` - Used to observe status updates.
731async fn from_url(url: &str, path: &Path, status: &impl Status) -> Result<(), Error> {
732	// Download the binary
733	status.update(&format!("Downloading from {url}..."));
734	download(url, path).await?;
735	status.update("Sourcing complete.");
736	Ok(())
737}
738
739/// Builds a package.
740///
741/// # Arguments
742/// * `manifest` - The path to the manifest.
743/// * `package` - The name of the package to be built.
744/// * `artifacts` - Any additional artifacts that are required.
745/// * `release` - Whether to build optimized artifacts using the release profile.
746/// * `status` - Used to observe status updates.
747/// * `verbose` - Whether verbose output is required.
748async 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	// Define arguments
757	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	// Build binaries
763	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	// Copy required artifacts to the destination path
777	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
788/// Downloads a file from a URL.
789///
790/// # Arguments
791/// * `url` - The url of the file.
792/// * `path` - The (local) destination path.
793async fn download(url: &str, dest: &Path) -> Result<(), Error> {
794	// Download to the destination path
795	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	// Make executable
799	set_executable_permission(dest)?;
800	Ok(())
801}
802
803/// Sets the executable permission for a given file.
804///
805/// # Arguments
806/// * `path` - The file path to which permissions should be granted.
807pub 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				// Determine release for comparison
968				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				// Check results for a specified/unspecified version
990				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				// Create a later version as a cached binary
1009				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			// 1 initial attempt + 3 retries = 4 total requests.
1393			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
1414/// Traits for the sourcing of a binary.
1415pub mod traits {
1416	/// The source of a binary.
1417	pub trait Source {
1418		/// The type returned in the event of an error.
1419		type Error;
1420
1421		/// Defines the source of a binary.
1422		fn source(&self) -> Result<super::Source, Self::Error>;
1423	}
1424
1425	/// Traits for the sourcing of a binary using [strum]-based configuration.
1426	pub mod enums {
1427		use strum::EnumProperty;
1428
1429		/// The source of a binary.
1430		pub trait Source {
1431			/// The name of the binary.
1432			fn binary(&self) -> &'static str;
1433
1434			/// The fallback version to be used when the latest version cannot be determined.
1435			fn fallback(&self) -> &str;
1436
1437			/// Whether pre-releases are to be used.
1438			fn prerelease(&self) -> Option<bool>;
1439		}
1440
1441		/// The source of a binary.
1442		pub trait Repository: Source {
1443			/// The repository to be used.
1444			fn repository(&self) -> &str;
1445
1446			/// If applicable, a pattern to be used to determine applicable releases along with
1447			/// subcomponents from a release tag - e.g. `polkadot-{version}`.
1448			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
1525/// Filters which can be used when resolving a binary.
1526pub mod filters {
1527	/// A filter which ensures a candidate file name starts with a prefix.
1528	///
1529	/// # Arguments
1530	/// * `candidate` - the candidate to be evaluated.
1531	/// * `prefix` - the specified prefix.
1532	pub fn prefix(candidate: &str, prefix: &str) -> bool {
1533		candidate.starts_with(prefix) &&
1534			// Ignore any known related `polkadot`-prefixed binaries when `polkadot` only.
1535			(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			// polkadot binary should match itself
1553			assert!(prefix("polkadot", "polkadot"));
1554			assert!(prefix("polkadot-stable2512", "polkadot"));
1555			assert!(prefix("polkadot-stable2512-1", "polkadot"));
1556
1557			// But should NOT match these related binaries
1558			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			// Other binaries should work normally
1568			assert!(prefix("polkadot-parachain", "polkadot-parachain"));
1569			assert!(prefix("polkadot-omni-node", "polkadot-omni-node"));
1570		}
1571	}
1572}