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 std::{
11	collections::HashMap,
12	error::Error as _,
13	fs::{File, copy, metadata, read_dir, rename},
14	io::{BufRead, Seek, SeekFrom, Write},
15	os::unix::fs::PermissionsExt,
16	path::{Path, PathBuf},
17	time::Duration,
18};
19use tar::Archive;
20use tempfile::{tempdir, tempfile};
21use thiserror::Error;
22use url::Url;
23
24mod binary;
25
26/// An error relating to the sourcing of binaries.
27#[derive(Error, Debug)]
28pub enum Error {
29	/// An error occurred.
30	#[error("Anyhow error: {0}")]
31	AnyhowError(#[from] anyhow::Error),
32	/// An API error occurred.
33	#[error("API error: {0}")]
34	ApiError(#[from] api::Error),
35	/// An error occurred sourcing a binary from an archive.
36	#[error("Archive error: {0}")]
37	ArchiveError(String),
38	/// A HTTP error occurred.
39	#[error("HTTP error: {0} caused by {:?}", reqwest::Error::source(.0))]
40	HttpError(#[from] reqwest::Error),
41	/// An IO error occurred.
42	#[error("IO error: {0}")]
43	IO(#[from] std::io::Error),
44	/// A binary cannot be sourced.
45	#[error("Missing binary: {0}")]
46	MissingBinary(String),
47	/// An error occurred during parsing.
48	#[error("ParseError error: {0}")]
49	ParseError(#[from] url::ParseError),
50}
51
52/// The source of a binary.
53#[derive(Clone, Debug, PartialEq)]
54pub enum Source {
55	/// An archive for download.
56	#[allow(dead_code)]
57	Archive {
58		/// The url of the archive.
59		url: String,
60		/// The archive contents required, including the binary name.
61		contents: Vec<String>,
62	},
63	/// A git repository.
64	Git {
65		/// The url of the repository.
66		url: Url,
67		/// If applicable, the branch, tag or commit.
68		reference: Option<String>,
69		/// If applicable, a specification of the path to the manifest.
70		manifest: Option<PathBuf>,
71		/// The name of the package to be built.
72		package: String,
73		/// Any additional build artifacts that are required.
74		artifacts: Vec<String>,
75	},
76	/// A GitHub repository.
77	GitHub(GitHub),
78	/// A URL for download.
79	#[allow(dead_code)]
80	Url {
81		/// The URL for download.
82		url: String,
83		/// The name of the binary.
84		name: String,
85	},
86}
87
88impl Source {
89	/// Sources the binary.
90	///
91	/// # Arguments
92	/// * `cache` - the cache to be used.
93	/// * `release` - whether any binaries needing to be built should be done so using the release
94	///   profile.
95	/// * `status` - used to observe status updates.
96	/// * `verbose` - whether verbose output is required.
97	pub(super) async fn source(
98		&self,
99		cache: &Path,
100		release: bool,
101		status: &impl Status,
102		verbose: bool,
103	) -> Result<(), Error> {
104		use Source::*;
105		match self {
106			Archive { url, contents } => {
107				let contents: Vec<_> = contents
108					.iter()
109					.map(|name| ArchiveFileSpec::new(name.into(), Some(cache.join(name)), true))
110					.collect();
111				from_archive(url, &contents, status).await
112			},
113			Git { url, reference, manifest, package, artifacts } => {
114				let artifacts: Vec<_> = artifacts
115					.iter()
116					.map(|name| match reference {
117						Some(version) => (name.as_str(), cache.join(format!("{name}-{version}"))),
118						None => (name.as_str(), cache.join(name)),
119					})
120					.collect();
121				from_git(
122					url.as_str(),
123					reference.as_deref(),
124					manifest.as_ref(),
125					package,
126					&artifacts,
127					release,
128					status,
129					verbose,
130				)
131				.await
132			},
133			GitHub(source) => source.source(cache, release, status, verbose).await,
134			Url { url, name } => from_url(url, &cache.join(name), status).await,
135		}
136	}
137
138	/// Performs any additional processing required to resolve the binary from a source.
139	///
140	/// Determines whether the binary already exists locally, using the latest version available,
141	/// and whether there are any newer versions available
142	///
143	/// # Arguments
144	/// * `name` - the name of the binary.
145	/// * `version` - a specific version of the binary required.
146	/// * `cache` - the cache being used.
147	/// * `cache_filter` - a filter to be used to determine whether a cached binary is eligible.
148	pub async fn resolve(
149		self,
150		name: &str,
151		version: Option<&str>,
152		cache: &Path,
153		cache_filter: impl for<'a> FnOnce(&'a str) -> bool + Copy,
154	) -> Self {
155		match self {
156			Source::GitHub(github) =>
157				Source::GitHub(github.resolve(name, version, cache, cache_filter).await),
158			_ => self,
159		}
160	}
161}
162
163/// A binary sourced from GitHub.
164#[derive(Clone, Debug, Derivative)]
165#[derivative(PartialEq)]
166pub enum GitHub {
167	/// An archive for download from a GitHub release.
168	ReleaseArchive {
169		/// The owner of the repository - i.e. <https://github.com/{owner}/repository>.
170		owner: String,
171		/// The name of the repository - i.e. <https://github.com/owner/{repository}>.
172		repository: String,
173		/// The release tag to be used, where `None` is latest.
174		tag: Option<String>,
175		/// If applicable, a pattern to be used to determine applicable releases along with
176		/// determining subcomponents from a release tag - e.g. `polkadot-{version}`.
177		tag_pattern: Option<TagPattern>,
178		/// Whether pre-releases are to be used.
179		prerelease: bool,
180		/// A function that orders candidates for selection when multiple versions are available.
181		#[derivative(PartialEq = "ignore")]
182		version_comparator: for<'a> fn(&'a mut [String]) -> SortedSlice<'a, String>,
183		/// The version to use if an appropriate version cannot be resolved.
184		fallback: String,
185		/// The name of the archive (asset) to download.
186		archive: String,
187		/// The archive contents required.
188		contents: Vec<ArchiveFileSpec>,
189		/// If applicable, the latest release tag available.
190		latest: Option<String>,
191	},
192	/// A source code archive for download from GitHub.
193	SourceCodeArchive {
194		/// The owner of the repository - i.e. <https://github.com/{owner}/repository>.
195		owner: String,
196		/// The name of the repository - i.e. <https://github.com/owner/{repository}>.
197		repository: String,
198		/// If applicable, the branch, tag or commit.
199		reference: Option<String>,
200		/// If applicable, a specification of the path to the manifest.
201		manifest: Option<PathBuf>,
202		/// The name of the package to be built.
203		package: String,
204		/// Any additional artifacts that are required.
205		artifacts: Vec<String>,
206	},
207}
208
209impl GitHub {
210	/// Sources the binary.
211	///
212	/// # Arguments
213	///
214	/// * `cache` - the cache to be used.
215	/// * `release` - whether any binaries needing to be built should be done so using the release
216	///   profile.
217	/// * `status` - used to observe status updates.
218	/// * `verbose` - whether verbose output is required.
219	async fn source(
220		&self,
221		cache: &Path,
222		release: bool,
223		status: &impl Status,
224		verbose: bool,
225	) -> Result<(), Error> {
226		use GitHub::*;
227		match self {
228			ReleaseArchive { owner, repository, tag, tag_pattern, archive, contents, .. } => {
229				// Complete url and contents based on the tag
230				let base_url = format!("https://github.com/{owner}/{repository}/releases");
231				let url = match tag.as_ref() {
232					Some(tag) => {
233						format!("{base_url}/download/{tag}/{archive}")
234					},
235					None => format!("{base_url}/latest/download/{archive}"),
236				};
237				let contents: Vec<_> = contents
238					.iter()
239					.map(|ArchiveFileSpec { name, target, required }| match tag.as_ref() {
240						Some(tag) => ArchiveFileSpec::new(
241							name.into(),
242							Some(cache.join(format!(
243									"{}-{}",
244									target.as_ref().map_or(name.as_str(), |t| t
245										.to_str()
246										.expect("expected target file name to be valid utf-8")),
247									tag_pattern
248										.as_ref()
249										.and_then(|pattern| pattern.version(tag))
250										.unwrap_or(tag)
251								))),
252							*required,
253						),
254						None => ArchiveFileSpec::new(
255							name.into(),
256							Some(cache.join(target.as_ref().map_or(name.as_str(), |t| {
257								t.to_str().expect("expected target file name to be valid utf-8")
258							}))),
259							*required,
260						),
261					})
262					.collect();
263				from_archive(&url, &contents, status).await
264			},
265			SourceCodeArchive { owner, repository, reference, manifest, package, artifacts } => {
266				let artifacts: Vec<_> = artifacts
267					.iter()
268					.map(|name| match reference {
269						Some(reference) =>
270							(name.as_str(), cache.join(format!("{name}-{reference}"))),
271						None => (name.as_str(), cache.join(name)),
272					})
273					.collect();
274				from_github_archive(
275					owner,
276					repository,
277					reference.as_ref().map(|r| r.as_str()),
278					manifest.as_ref(),
279					package,
280					&artifacts,
281					release,
282					status,
283					verbose,
284				)
285				.await
286			},
287		}
288	}
289
290	/// Performs any additional processing required to resolve the binary from a source.
291	///
292	/// Determines whether the binary already exists locally, using the latest version available,
293	/// and whether there are any newer versions available
294	///
295	/// # Arguments
296	/// * `name` - the name of the binary.
297	/// * `version` - a specific version of the binary required.
298	/// * `cache` - the cache being used.
299	/// * `cache_filter` - a filter to be used to determine whether a cached binary is eligible.
300	async fn resolve(
301		self,
302		name: &str,
303		version: Option<&str>,
304		cache: &Path,
305		cache_filter: impl FnOnce(&str) -> bool + Copy,
306	) -> Self {
307		match self {
308			Self::ReleaseArchive {
309				owner,
310				repository,
311				tag: _,
312				tag_pattern,
313				prerelease,
314				version_comparator,
315				fallback,
316				archive,
317				contents,
318				latest: _,
319			} => {
320				// Get releases, defaulting to the specified fallback version if there's an error.
321				let repo = crate::GitHub::new(owner.as_str(), repository.as_str());
322				let mut releases = repo.releases(prerelease).await.unwrap_or_else(|_e| {
323					// Use any specified version or fall back to the last known version.
324					let version = version.unwrap_or(fallback.as_str());
325					vec![Release {
326						tag_name: tag_pattern.as_ref().map_or_else(
327							|| version.to_string(),
328							|pattern| pattern.resolve_tag(version),
329						),
330						name: String::default(),
331						prerelease,
332						commit: None,
333						published_at: String::default(),
334					}]
335				});
336
337				// Filter releases if a tag pattern specified
338				if let Some(pattern) = tag_pattern.as_ref() {
339					releases.retain(|r| pattern.regex.is_match(&r.tag_name));
340				}
341
342				// Select versions from release tags, used for resolving the candidate versions and
343				// local binary versioning.
344				let mut binaries: HashMap<_, _> = releases
345					.into_iter()
346					.map(|r| {
347						let version = tag_pattern
348							.as_ref()
349							.and_then(|pattern| pattern.version(&r.tag_name).map(|v| v.to_string()))
350							.unwrap_or_else(|| r.tag_name.clone());
351						(version, r.tag_name)
352					})
353					.collect();
354
355				// Resolve any specified version - i.e., the version could be provided as a concrete
356				// version or just a tag.
357				let version = version.map(|v| {
358					tag_pattern
359						.as_ref()
360						.and_then(|pattern| pattern.version(v))
361						.unwrap_or(v)
362						.to_string()
363				});
364
365				// Extract versions from any cached binaries - e.g., offline or rate-limited.
366				let cached_files = read_dir(cache).into_iter().flatten();
367				let cached_file_names = cached_files
368					.filter_map(|f| f.ok().and_then(|f| f.file_name().into_string().ok()));
369				for file in cached_file_names.filter(|f| cache_filter(f)) {
370					let version = file.replace(&format!("{name}-"), "");
371					let tag = tag_pattern.as_ref().map_or_else(
372						|| version.to_string(),
373						|pattern| pattern.resolve_tag(&version),
374					);
375					binaries.insert(version, tag);
376				}
377
378				// Prepare for version resolution by sorting by configured version comparator.
379				let mut versions: Vec<_> = binaries.keys().cloned().collect();
380				let versions = version_comparator(versions.as_mut_slice());
381
382				// Define the tag to be used as either a specified version or the latest available
383				// locally.
384				let tag = version.as_ref().map_or_else(
385					|| {
386						// Resolve the version to be used.
387						let resolved_version =
388							Binary::resolve_version(name, None, &versions, cache);
389						resolved_version.and_then(|v| binaries.get(v)).cloned()
390					},
391					|v| {
392						// Ensure any specified version is a tag.
393						Some(
394							tag_pattern
395								.as_ref()
396								.map_or_else(|| v.to_string(), |pattern| pattern.resolve_tag(v)),
397						)
398					},
399				);
400
401				// // Default to the latest version when no specific version is provided by the
402				// caller.
403				let latest: Option<String> = version
404					.is_none()
405					.then(|| versions.first().and_then(|v| binaries.get(v.as_str()).cloned()))
406					.flatten();
407
408				Self::ReleaseArchive {
409					owner,
410					repository,
411					tag,
412					tag_pattern,
413					prerelease,
414					version_comparator,
415					fallback,
416					archive,
417					contents,
418					latest,
419				}
420			},
421			_ => self,
422		}
423	}
424}
425
426/// A specification of a file within an archive.
427#[derive(Clone, Debug, PartialEq)]
428pub struct ArchiveFileSpec {
429	/// The name of the file within the archive.
430	pub name: String,
431	/// An optional file name to be used for the file once extracted.
432	pub target: Option<PathBuf>,
433	/// Whether the file is required.
434	pub required: bool,
435}
436
437impl ArchiveFileSpec {
438	/// A specification of a file within an archive.
439	///
440	/// # Arguments
441	/// * `name` - The name of the file within the archive.
442	/// * `target` - An optional file name to be used for the file once extracted.
443	/// * `required` - Whether the file is required.
444	pub fn new(name: String, target: Option<PathBuf>, required: bool) -> Self {
445		Self { name, target, required }
446	}
447}
448
449/// A pattern used to determine captures from a release tag.
450///
451/// Only `{version}` is currently supported, used to determine a version from a release tag.
452/// Examples: `polkadot-{version}`, `node-{version}`.
453#[derive(Clone, Debug)]
454pub struct TagPattern {
455	regex: Regex,
456	pattern: String,
457}
458
459impl TagPattern {
460	/// A new pattern used to determine captures from a release tag.
461	///
462	/// # Arguments
463	/// * `pattern` - the pattern to be used.
464	pub fn new(pattern: &str) -> Self {
465		Self {
466			regex: Regex::new(&format!("^{}$", pattern.replace("{version}", "(?P<version>.+)")))
467				.expect("expected valid regex"),
468			pattern: pattern.into(),
469		}
470	}
471
472	/// Resolves a tag for the specified value.
473	///
474	/// # Arguments
475	/// * `value` - the value to resolve into a tag using the inner tag pattern.
476	pub fn resolve_tag(&self, value: &str) -> String {
477		// If input already in expected tag format, return as-is.
478		if self.regex.is_match(value) {
479			return value.to_string();
480		}
481
482		self.pattern.replace("{version}", value)
483	}
484
485	/// Extracts a version from the specified value.
486	///
487	/// # Arguments
488	/// * `value` - the value to parse.
489	pub fn version<'a>(&self, value: &'a str) -> Option<&'a str> {
490		self.regex.captures(value).and_then(|c| c.name("version").map(|v| v.as_str()))
491	}
492}
493
494impl PartialEq for TagPattern {
495	fn eq(&self, other: &Self) -> bool {
496		self.regex.as_str() == other.regex.as_str() && self.pattern == other.pattern
497	}
498}
499
500impl From<&str> for TagPattern {
501	fn from(value: &str) -> Self {
502		Self::new(value)
503	}
504}
505
506/// Source binary by downloading and extracting from an archive.
507///
508/// # Arguments
509/// * `url` - The url of the archive.
510/// * `contents` - The contents within the archive which are required.
511/// * `status` - Used to observe status updates.
512async fn from_archive(
513	url: &str,
514	contents: &[ArchiveFileSpec],
515	status: &impl Status,
516) -> Result<(), Error> {
517	// Download archive
518	status.update(&format!("Downloading from {url}..."));
519	let response = reqwest::get(url).await?.error_for_status()?;
520	let mut file = tempfile()?;
521	file.write_all(&response.bytes().await?)?;
522	file.seek(SeekFrom::Start(0))?;
523	// Extract contents
524	status.update("Extracting from archive...");
525	let tar = GzDecoder::new(file);
526	let mut archive = Archive::new(tar);
527	let temp_dir = tempdir()?;
528	let working_dir = temp_dir.path();
529	archive.unpack(working_dir)?;
530	for ArchiveFileSpec { name, target, required } in contents {
531		let src = working_dir.join(name);
532		if src.exists() {
533			set_executable_permission(&src)?;
534			if let Some(target) = target &&
535				let Err(_e) = rename(&src, target)
536			{
537				// If rename fails (e.g., due to cross-device linking), fallback to copy and
538				// remove
539				copy(&src, target)?;
540				std::fs::remove_file(&src)?;
541			}
542		} else if *required {
543			return Err(Error::ArchiveError(format!(
544				"Expected file '{}' in archive, but it was not found.",
545				name
546			)));
547		}
548	}
549	status.update("Sourcing complete.");
550	Ok(())
551}
552
553/// Source binary by cloning a git repository and then building.
554///
555/// # Arguments
556/// * `url` - The url of the repository.
557/// * `reference` - If applicable, the branch, tag or commit.
558/// * `manifest` - If applicable, a specification of the path to the manifest.
559/// * `package` - The name of the package to be built.
560/// * `artifacts` - Any additional artifacts that are required.
561/// * `release` - Whether to build optimized artifacts using the release profile.
562/// * `status` - Used to observe status updates.
563/// * `verbose` - Whether verbose output is required.
564#[allow(clippy::too_many_arguments)]
565async fn from_git(
566	url: &str,
567	reference: Option<&str>,
568	manifest: Option<impl AsRef<Path>>,
569	package: &str,
570	artifacts: &[(&str, impl AsRef<Path>)],
571	release: bool,
572	status: &impl Status,
573	verbose: bool,
574) -> Result<(), Error> {
575	// Clone repository into working directory
576	let temp_dir = tempdir()?;
577	let working_dir = temp_dir.path();
578	status.update(&format!("Cloning {url}..."));
579	Git::clone(&Url::parse(url)?, working_dir, reference)?;
580	// Build binaries
581	status.update("Starting build of binary...");
582	let manifest = manifest
583		.as_ref()
584		.map_or_else(|| working_dir.join("Cargo.toml"), |m| working_dir.join(m));
585	build(manifest, package, artifacts, release, status, verbose).await?;
586	status.update("Sourcing complete.");
587	Ok(())
588}
589
590/// Source binary by downloading from a source code archive and then building.
591///
592/// # Arguments
593/// * `owner` - The owner of the repository.
594/// * `repository` - The name of the repository.
595/// * `reference` - If applicable, the branch, tag or commit.
596/// * `manifest` - If applicable, a specification of the path to the manifest.
597/// * `package` - The name of the package to be built.
598/// * `artifacts` - Any additional artifacts that are required.
599/// * `release` - Whether to build optimized artifacts using the release profile.
600/// * `status` - Used to observe status updates.
601/// * `verbose` - Whether verbose output is required.
602#[allow(clippy::too_many_arguments)]
603async fn from_github_archive(
604	owner: &str,
605	repository: &str,
606	reference: Option<&str>,
607	manifest: Option<impl AsRef<Path>>,
608	package: &str,
609	artifacts: &[(&str, impl AsRef<Path>)],
610	release: bool,
611	status: &impl Status,
612	verbose: bool,
613) -> Result<(), Error> {
614	// User agent required when using GitHub API
615	let response = match reference {
616		Some(reference) => {
617			// Various potential urls to try based on not knowing the type of ref
618			let urls = [
619				format!(
620					"https://github.com/{owner}/{repository}/archive/refs/heads/{reference}.tar.gz"
621				),
622				format!(
623					"https://github.com/{owner}/{repository}/archive/refs/tags/{reference}.tar.gz"
624				),
625				format!("https://github.com/{owner}/{repository}/archive/{reference}.tar.gz"),
626			];
627			let mut response = None;
628			for url in urls {
629				status.update(&format!("Downloading from {url}..."));
630				response = Some(GITHUB_API_CLIENT.get(url).await);
631				if let Some(Err(api::Error::HttpError(e))) = &response &&
632					e.status() == Some(StatusCode::NOT_FOUND)
633				{
634					tokio::time::sleep(Duration::from_secs(1)).await;
635					continue;
636				}
637				break;
638			}
639			response.expect("value set above")?
640		},
641		None => {
642			let url = format!("https://api.github.com/repos/{owner}/{repository}/tarball");
643			status.update(&format!("Downloading from {url}..."));
644			GITHUB_API_CLIENT.get(url).await?
645		},
646	};
647	let mut file = tempfile()?;
648	file.write_all(&response)?;
649	file.seek(SeekFrom::Start(0))?;
650	// Extract contents
651	status.update("Extracting from archive...");
652	let tar = GzDecoder::new(file);
653	let mut archive = Archive::new(tar);
654	let temp_dir = tempdir()?;
655	let mut working_dir = temp_dir.path().into();
656	archive.unpack(&working_dir)?;
657	// Prepare archive contents for build
658	let entries: Vec<_> = read_dir(&working_dir)?.take(2).filter_map(|x| x.ok()).collect();
659	match entries.len() {
660		0 => {
661			return Err(Error::ArchiveError(
662				"The downloaded archive does not contain any entries.".into(),
663			));
664		},
665		1 => working_dir = entries[0].path(), // Automatically switch to top level directory
666		_ => {},                              /* Assume that downloaded archive does not have a
667		                                        * top level directory */
668	}
669	// Build binaries
670	status.update("Starting build of binary...");
671	let manifest = manifest
672		.as_ref()
673		.map_or_else(|| working_dir.join("Cargo.toml"), |m| working_dir.join(m));
674	build(&manifest, package, artifacts, release, status, verbose).await?;
675	status.update("Sourcing complete.");
676	Ok(())
677}
678
679/// Source binary by building a local package.
680///
681/// # Arguments
682/// * `manifest` - The path to the local package manifest.
683/// * `package` - The name of the package to be built.
684/// * `release` - Whether to build optimized artifacts using the release profile.
685/// * `status` - Used to observe status updates.
686/// * `verbose` - Whether verbose output is required.
687pub(crate) async fn from_local_package(
688	manifest: &Path,
689	package: &str,
690	release: bool,
691	status: &impl Status,
692	verbose: bool,
693) -> Result<(), Error> {
694	// Build binaries
695	status.update("Starting build of binary...");
696	const EMPTY: [(&str, PathBuf); 0] = [];
697	build(manifest, package, &EMPTY, release, status, verbose).await?;
698	status.update("Sourcing complete.");
699	Ok(())
700}
701
702/// Source binary by downloading from a URL.
703///
704/// # Arguments
705/// * `url` - The url of the binary.
706/// * `path` - The (local) destination path.
707/// * `status` - Used to observe status updates.
708async fn from_url(url: &str, path: &Path, status: &impl Status) -> Result<(), Error> {
709	// Download the binary
710	status.update(&format!("Downloading from {url}..."));
711	download(url, path).await?;
712	status.update("Sourcing complete.");
713	Ok(())
714}
715
716/// Builds a package.
717///
718/// # Arguments
719/// * `manifest` - The path to the manifest.
720/// * `package` - The name of the package to be built.
721/// * `artifacts` - Any additional artifacts that are required.
722/// * `release` - Whether to build optimized artifacts using the release profile.
723/// * `status` - Used to observe status updates.
724/// * `verbose` - Whether verbose output is required.
725async fn build(
726	manifest: impl AsRef<Path>,
727	package: &str,
728	artifacts: &[(&str, impl AsRef<Path>)],
729	release: bool,
730	status: &impl Status,
731	verbose: bool,
732) -> Result<(), Error> {
733	// Define arguments
734	let manifest_path = manifest.as_ref().to_str().expect("expected manifest path to be valid");
735	let mut args = vec!["build", "-p", package, "--manifest-path", manifest_path];
736	if release {
737		args.push("--release")
738	}
739	// Build binaries
740	let command = cmd("cargo", args);
741	match verbose {
742		false => {
743			let reader = command.stderr_to_stdout().reader()?;
744			let output = std::io::BufReader::new(reader).lines();
745			for line in output {
746				status.update(&line?);
747			}
748		},
749		true => {
750			command.run()?;
751		},
752	}
753	// Copy required artifacts to the destination path
754	let target = manifest
755		.as_ref()
756		.parent()
757		.expect("expected parent directory to be valid")
758		.join(format!("target/{}", if release { "release" } else { "debug" }));
759	for (name, dest) in artifacts {
760		copy(target.join(name), dest)?;
761	}
762	Ok(())
763}
764
765/// Downloads a file from a URL.
766///
767/// # Arguments
768/// * `url` - The url of the file.
769/// * `path` - The (local) destination path.
770async fn download(url: &str, dest: &Path) -> Result<(), Error> {
771	// Download to the destination path
772	let response = reqwest::get(url).await?.error_for_status()?;
773	let mut file = File::create(dest)?;
774	file.write_all(&response.bytes().await?)?;
775	// Make executable
776	set_executable_permission(dest)?;
777	Ok(())
778}
779
780/// Sets the executable permission for a given file.
781///
782/// # Arguments
783/// * `path` - The file path to which permissions should be granted.
784pub fn set_executable_permission<P: AsRef<Path>>(path: P) -> Result<(), Error> {
785	let mut perms = metadata(&path)?.permissions();
786	perms.set_mode(0o755);
787	std::fs::set_permissions(path, perms)?;
788	Ok(())
789}
790
791#[cfg(test)]
792pub(super) mod tests {
793	use super::{GitHub::*, Status, *};
794	use crate::{polkadot_sdk::parse_version, target};
795	use tempfile::tempdir;
796
797	#[tokio::test]
798	async fn sourcing_from_archive_works() -> anyhow::Result<()> {
799		let url = "https://github.com/r0gue-io/polkadot/releases/latest/download/polkadot-aarch64-apple-darwin.tar.gz".to_string();
800		let name = "polkadot".to_string();
801		let contents =
802			vec![name.clone(), "polkadot-execute-worker".into(), "polkadot-prepare-worker".into()];
803		let temp_dir = tempdir()?;
804
805		Source::Archive { url, contents: contents.clone() }
806			.source(temp_dir.path(), true, &Output, true)
807			.await?;
808		for item in contents {
809			assert!(temp_dir.path().join(item).exists());
810		}
811		Ok(())
812	}
813
814	#[tokio::test]
815	async fn resolve_from_archive_is_noop() -> anyhow::Result<()> {
816		let url = "https://github.com/r0gue-io/polkadot/releases/latest/download/polkadot-aarch64-apple-darwin.tar.gz".to_string();
817		let name = "polkadot".to_string();
818		let contents =
819			vec![name.clone(), "polkadot-execute-worker".into(), "polkadot-prepare-worker".into()];
820		let temp_dir = tempdir()?;
821
822		let source = Source::Archive { url, contents: contents.clone() };
823		assert_eq!(
824			source.clone().resolve(&name, None, temp_dir.path(), filters::polkadot).await,
825			source
826		);
827		Ok(())
828	}
829
830	#[tokio::test]
831	async fn sourcing_from_git_works() -> anyhow::Result<()> {
832		let url = Url::parse("https://github.com/hpaluch/rust-hello-world")?;
833		let package = "hello_world".to_string();
834		let temp_dir = tempdir()?;
835
836		Source::Git {
837			url,
838			reference: None,
839			manifest: None,
840			package: package.clone(),
841			artifacts: vec![package.clone()],
842		}
843		.source(temp_dir.path(), true, &Output, true)
844		.await?;
845		assert!(temp_dir.path().join(package).exists());
846		Ok(())
847	}
848
849	#[tokio::test]
850	async fn resolve_from_git_is_noop() -> anyhow::Result<()> {
851		let url = Url::parse("https://github.com/hpaluch/rust-hello-world")?;
852		let package = "hello_world".to_string();
853		let temp_dir = tempdir()?;
854
855		let source = Source::Git {
856			url,
857			reference: None,
858			manifest: None,
859			package: package.clone(),
860			artifacts: vec![package.clone()],
861		};
862		assert_eq!(
863			source
864				.clone()
865				.resolve(&package, None, temp_dir.path(), |f| filters::prefix(f, &package))
866				.await,
867			source
868		);
869		Ok(())
870	}
871
872	#[tokio::test]
873	async fn sourcing_from_git_ref_works() -> anyhow::Result<()> {
874		let url = Url::parse("https://github.com/hpaluch/rust-hello-world")?;
875		let initial_commit = "436b7dbffdfaaf7ad90bf44ae8fdcb17eeee65a3".to_string();
876		let package = "hello_world".to_string();
877		let temp_dir = tempdir()?;
878
879		Source::Git {
880			url,
881			reference: Some(initial_commit.clone()),
882			manifest: None,
883			package: package.clone(),
884			artifacts: vec![package.clone()],
885		}
886		.source(temp_dir.path(), true, &Output, true)
887		.await?;
888		assert!(temp_dir.path().join(format!("{package}-{initial_commit}")).exists());
889		Ok(())
890	}
891
892	#[tokio::test]
893	async fn sourcing_from_github_release_archive_works() -> anyhow::Result<()> {
894		let owner = "r0gue-io".to_string();
895		let repository = "polkadot".to_string();
896		let version = "stable2503";
897		let tag_pattern = Some("polkadot-{version}".into());
898		let fallback = "stable2412-4".into();
899		let archive = format!("polkadot-{}.tar.gz", target()?);
900		let contents = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"];
901		let temp_dir = tempdir()?;
902
903		Source::GitHub(ReleaseArchive {
904			owner,
905			repository,
906			tag: Some(format!("polkadot-{version}")),
907			tag_pattern,
908			prerelease: false,
909			version_comparator,
910			fallback,
911			archive,
912			contents: contents.map(|n| ArchiveFileSpec::new(n.into(), None, true)).to_vec(),
913			latest: None,
914		})
915		.source(temp_dir.path(), true, &Output, true)
916		.await?;
917		for item in contents {
918			assert!(temp_dir.path().join(format!("{item}-{version}")).exists());
919		}
920		Ok(())
921	}
922
923	#[tokio::test]
924	async fn resolve_from_github_release_archive_works() -> anyhow::Result<()> {
925		let owner = "r0gue-io".to_string();
926		let repository = "polkadot".to_string();
927		let version = "stable2503";
928		let tag_pattern = Some("polkadot-{version}".into());
929		let fallback = "stable2412-4".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		// Determine release for comparison
935		let mut releases: Vec<_> = crate::GitHub::new(owner.as_str(), repository.as_str())
936			.releases(false)
937			.await?
938			.into_iter()
939			.map(|r| r.tag_name)
940			.collect();
941		let sorted_releases = version_comparator(releases.as_mut_slice());
942
943		let source = Source::GitHub(ReleaseArchive {
944			owner,
945			repository,
946			tag: None,
947			tag_pattern,
948			prerelease: false,
949			version_comparator,
950			fallback,
951			archive,
952			contents: contents.map(|n| ArchiveFileSpec::new(n.into(), None, true)).to_vec(),
953			latest: None,
954		});
955
956		// Check results for a specified/unspecified version
957		for version in [Some(version), None] {
958			let source = source
959				.clone()
960				.resolve("polkadot", version, temp_dir.path(), filters::polkadot)
961				.await;
962			let expected_tag = version.map_or_else(
963				|| sorted_releases.0.first().unwrap().into(),
964				|v| format!("polkadot-{v}"),
965			);
966			let expected_latest = version.map_or_else(|| sorted_releases.0.first(), |_| None);
967			assert!(matches!(
968				source,
969				Source::GitHub(ReleaseArchive { tag, latest, .. } )
970					if tag == Some(expected_tag) && latest.as_ref() == expected_latest
971			));
972		}
973
974		// Create a later version as a cached binary
975		let cached_version = "polkadot-stable2612";
976		File::create(temp_dir.path().join(cached_version))?;
977		for version in [Some(version), None] {
978			let source = source
979				.clone()
980				.resolve("polkadot", version, temp_dir.path(), filters::polkadot)
981				.await;
982			let expected_tag =
983				version.map_or_else(|| cached_version.to_string(), |v| format!("polkadot-{v}"));
984			let expected_latest =
985				version.map_or_else(|| Some(cached_version.to_string()), |_| None);
986			assert!(matches!(
987				source,
988				Source::GitHub(ReleaseArchive { tag, latest, .. } )
989					if tag == Some(expected_tag) && latest == expected_latest
990			));
991		}
992
993		Ok(())
994	}
995
996	#[tokio::test]
997	async fn sourcing_from_github_release_archive_maps_contents() -> anyhow::Result<()> {
998		let owner = "r0gue-io".to_string();
999		let repository = "polkadot".to_string();
1000		let version = "stable2503";
1001		let tag_pattern = Some("polkadot-{version}".into());
1002		let name = "polkadot".to_string();
1003		let fallback = "stable2412-4".into();
1004		let archive = format!("{name}-{}.tar.gz", target()?);
1005		let contents = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"];
1006		let temp_dir = tempdir()?;
1007		let prefix = "test";
1008
1009		Source::GitHub(ReleaseArchive {
1010			owner,
1011			repository,
1012			tag: Some(format!("polkadot-{version}")),
1013			tag_pattern,
1014			prerelease: false,
1015			version_comparator,
1016			fallback,
1017			archive,
1018			contents: contents
1019				.map(|n| ArchiveFileSpec::new(n.into(), Some(format!("{prefix}-{n}").into()), true))
1020				.to_vec(),
1021			latest: None,
1022		})
1023		.source(temp_dir.path(), true, &Output, true)
1024		.await?;
1025		for item in contents {
1026			assert!(temp_dir.path().join(format!("{prefix}-{item}-{version}")).exists());
1027		}
1028		Ok(())
1029	}
1030
1031	#[tokio::test]
1032	async fn sourcing_from_latest_github_release_archive_works() -> anyhow::Result<()> {
1033		let owner = "r0gue-io".to_string();
1034		let repository = "polkadot".to_string();
1035		let tag_pattern = Some("polkadot-{version}".into());
1036		let name = "polkadot".to_string();
1037		let fallback = "stable2412-4".into();
1038		let archive = format!("{name}-{}.tar.gz", target()?);
1039		let contents = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"];
1040		let temp_dir = tempdir()?;
1041
1042		Source::GitHub(ReleaseArchive {
1043			owner,
1044			repository,
1045			tag: None,
1046			tag_pattern,
1047			prerelease: false,
1048			version_comparator,
1049			fallback,
1050			archive,
1051			contents: contents.map(|n| ArchiveFileSpec::new(n.into(), None, true)).to_vec(),
1052			latest: None,
1053		})
1054		.source(temp_dir.path(), true, &Output, true)
1055		.await?;
1056		for item in contents {
1057			assert!(temp_dir.path().join(item).exists());
1058		}
1059		Ok(())
1060	}
1061
1062	#[tokio::test]
1063	async fn sourcing_from_github_source_code_archive_works() -> anyhow::Result<()> {
1064		let owner = "paritytech".to_string();
1065		let repository = "polkadot-sdk".to_string();
1066		let package = "polkadot".to_string();
1067		let temp_dir = tempdir()?;
1068		let initial_commit = "72dba98250a6267c61772cd55f8caf193141050f";
1069		let manifest = PathBuf::from("substrate/Cargo.toml");
1070
1071		Source::GitHub(SourceCodeArchive {
1072			owner,
1073			repository,
1074			reference: Some(initial_commit.to_string()),
1075			manifest: Some(manifest),
1076			package: package.clone(),
1077			artifacts: vec![package.clone()],
1078		})
1079		.source(temp_dir.path(), true, &Output, true)
1080		.await?;
1081		assert!(temp_dir.path().join(format!("{package}-{initial_commit}")).exists());
1082		Ok(())
1083	}
1084
1085	#[tokio::test]
1086	async fn resolve_from_github_source_code_archive_is_noop() -> anyhow::Result<()> {
1087		let owner = "paritytech".to_string();
1088		let repository = "polkadot-sdk".to_string();
1089		let package = "polkadot".to_string();
1090		let temp_dir = tempdir()?;
1091		let initial_commit = "72dba98250a6267c61772cd55f8caf193141050f";
1092		let manifest = PathBuf::from("substrate/Cargo.toml");
1093
1094		let source = Source::GitHub(SourceCodeArchive {
1095			owner,
1096			repository,
1097			reference: Some(initial_commit.to_string()),
1098			manifest: Some(manifest),
1099			package: package.clone(),
1100			artifacts: vec![package.clone()],
1101		});
1102		assert_eq!(
1103			source.clone().resolve(&package, None, temp_dir.path(), filters::polkadot).await,
1104			source
1105		);
1106		Ok(())
1107	}
1108
1109	#[tokio::test]
1110	async fn sourcing_from_latest_github_source_code_archive_works() -> anyhow::Result<()> {
1111		let owner = "hpaluch".to_string();
1112		let repository = "rust-hello-world".to_string();
1113		let package = "hello_world".to_string();
1114		let temp_dir = tempdir()?;
1115
1116		Source::GitHub(SourceCodeArchive {
1117			owner,
1118			repository,
1119			reference: None,
1120			manifest: None,
1121			package: package.clone(),
1122			artifacts: vec![package.clone()],
1123		})
1124		.source(temp_dir.path(), true, &Output, true)
1125		.await?;
1126		assert!(temp_dir.path().join(package).exists());
1127		Ok(())
1128	}
1129
1130	#[tokio::test]
1131	async fn sourcing_from_url_works() -> anyhow::Result<()> {
1132		let url =
1133			"https://github.com/paritytech/polkadot-sdk/releases/latest/download/polkadot.asc"
1134				.to_string();
1135		let name = "polkadot";
1136		let temp_dir = tempdir()?;
1137
1138		Source::Url { url, name: name.into() }
1139			.source(temp_dir.path(), false, &Output, true)
1140			.await?;
1141		assert!(temp_dir.path().join(name).exists());
1142		Ok(())
1143	}
1144
1145	#[tokio::test]
1146	async fn resolve_from_url_is_noop() -> anyhow::Result<()> {
1147		let url =
1148			"https://github.com/paritytech/polkadot-sdk/releases/latest/download/polkadot.asc"
1149				.to_string();
1150		let name = "polkadot";
1151		let temp_dir = tempdir()?;
1152
1153		let source = Source::Url { url, name: name.into() };
1154		assert_eq!(
1155			source.clone().resolve(name, None, temp_dir.path(), filters::polkadot).await,
1156			source
1157		);
1158		Ok(())
1159	}
1160
1161	#[tokio::test]
1162	async fn from_archive_works() -> anyhow::Result<()> {
1163		let temp_dir = tempdir()?;
1164		let url = "https://github.com/r0gue-io/polkadot/releases/latest/download/polkadot-aarch64-apple-darwin.tar.gz";
1165		let contents: Vec<_> = ["polkadot", "polkadot-execute-worker", "polkadot-prepare-worker"]
1166			.into_iter()
1167			.map(|b| ArchiveFileSpec::new(b.into(), Some(temp_dir.path().join(b)), true))
1168			.collect();
1169
1170		from_archive(url, &contents, &Output).await?;
1171		for ArchiveFileSpec { target, .. } in contents {
1172			assert!(target.unwrap().exists());
1173		}
1174		Ok(())
1175	}
1176
1177	#[tokio::test]
1178	async fn from_git_works() -> anyhow::Result<()> {
1179		let url = "https://github.com/hpaluch/rust-hello-world";
1180		let package = "hello_world";
1181		let initial_commit = "436b7dbffdfaaf7ad90bf44ae8fdcb17eeee65a3";
1182		let temp_dir = tempdir()?;
1183		let path = temp_dir.path().join(package);
1184
1185		from_git(
1186			url,
1187			Some(initial_commit),
1188			None::<&Path>,
1189			package,
1190			&[(package, &path)],
1191			true,
1192			&Output,
1193			false,
1194		)
1195		.await?;
1196		assert!(path.exists());
1197		Ok(())
1198	}
1199
1200	#[tokio::test]
1201	async fn from_github_archive_works() -> anyhow::Result<()> {
1202		let owner = "paritytech";
1203		let repository = "polkadot-sdk";
1204		let package = "polkadot";
1205		let temp_dir = tempdir()?;
1206		let path = temp_dir.path().join(package);
1207		let initial_commit = "72dba98250a6267c61772cd55f8caf193141050f";
1208		let manifest = "substrate/Cargo.toml";
1209
1210		from_github_archive(
1211			owner,
1212			repository,
1213			Some(initial_commit),
1214			Some(manifest),
1215			package,
1216			&[(package, &path)],
1217			true,
1218			&Output,
1219			true,
1220		)
1221		.await?;
1222		assert!(path.exists());
1223		Ok(())
1224	}
1225
1226	#[tokio::test]
1227	async fn from_latest_github_archive_works() -> anyhow::Result<()> {
1228		let owner = "hpaluch";
1229		let repository = "rust-hello-world";
1230		let package = "hello_world";
1231		let temp_dir = tempdir()?;
1232		let path = temp_dir.path().join(package);
1233
1234		from_github_archive(
1235			owner,
1236			repository,
1237			None,
1238			None::<&Path>,
1239			package,
1240			&[(package, &path)],
1241			true,
1242			&Output,
1243			true,
1244		)
1245		.await?;
1246		assert!(path.exists());
1247		Ok(())
1248	}
1249
1250	#[tokio::test]
1251	async fn from_local_package_works() -> anyhow::Result<()> {
1252		let temp_dir = tempdir()?;
1253		let name = "hello_world";
1254		cmd("cargo", ["new", name, "--bin"]).dir(temp_dir.path()).run()?;
1255		let manifest = temp_dir.path().join(name).join("Cargo.toml");
1256
1257		from_local_package(&manifest, name, false, &Output, true).await?;
1258		assert!(manifest.parent().unwrap().join("target/debug").join(name).exists());
1259		Ok(())
1260	}
1261
1262	#[tokio::test]
1263	async fn from_url_works() -> anyhow::Result<()> {
1264		let url =
1265			"https://github.com/paritytech/polkadot-sdk/releases/latest/download/polkadot.asc";
1266		let temp_dir = tempdir()?;
1267		let path = temp_dir.path().join("polkadot");
1268
1269		from_url(url, &path, &Output).await?;
1270		assert!(path.exists());
1271		assert_ne!(metadata(path)?.permissions().mode() & 0o755, 0);
1272		Ok(())
1273	}
1274
1275	#[test]
1276	fn tag_pattern_works() {
1277		let pattern: TagPattern = "polkadot-{version}".into();
1278		assert_eq!(pattern.regex.as_str(), "^polkadot-(?P<version>.+)$");
1279		assert_eq!(pattern.pattern, "polkadot-{version}");
1280		assert_eq!(pattern, pattern.clone());
1281
1282		for value in ["polkadot-stable2503", "stable2503"] {
1283			assert_eq!(pattern.resolve_tag(value).as_str(), "polkadot-stable2503");
1284		}
1285		assert_eq!(pattern.version("polkadot-stable2503"), Some("stable2503"));
1286	}
1287
1288	fn version_comparator<T: AsRef<str> + Ord>(versions: &'_ mut [T]) -> SortedSlice<'_, T> {
1289		SortedSlice::by(versions, |a, b| parse_version(b.as_ref()).cmp(&parse_version(a.as_ref())))
1290	}
1291
1292	pub(crate) struct Output;
1293	impl Status for Output {
1294		fn update(&self, status: &str) {
1295			println!("{status}")
1296		}
1297	}
1298}
1299
1300/// Traits for the sourcing of a binary.
1301pub mod traits {
1302	/// The source of a binary.
1303	pub trait Source {
1304		/// The type returned in the event of an error.
1305		type Error;
1306
1307		/// Defines the source of a binary.
1308		fn source(&self) -> Result<super::Source, Self::Error>;
1309	}
1310
1311	/// Traits for the sourcing of a binary using [strum]-based configuration.
1312	pub mod enums {
1313		use strum::EnumProperty;
1314
1315		/// The source of a binary.
1316		pub trait Source {
1317			/// The name of the binary.
1318			fn binary(&self) -> &'static str;
1319
1320			/// The fallback version to be used when the latest version cannot be determined.
1321			fn fallback(&self) -> &str;
1322
1323			/// Whether pre-releases are to be used.
1324			fn prerelease(&self) -> Option<bool>;
1325		}
1326
1327		/// The source of a binary.
1328		pub trait Repository: Source {
1329			/// The repository to be used.
1330			fn repository(&self) -> &str;
1331
1332			/// If applicable, a pattern to be used to determine applicable releases along with
1333			/// subcomponents from a release tag - e.g. `polkadot-{version}`.
1334			fn tag_pattern(&self) -> Option<&str>;
1335		}
1336
1337		impl<T: EnumProperty> Source for T {
1338			fn binary(&self) -> &'static str {
1339				self.get_str("Binary").expect("expected specification of `Binary` name")
1340			}
1341
1342			fn fallback(&self) -> &str {
1343				self.get_str("Fallback")
1344					.expect("expected specification of `Fallback` release tag")
1345			}
1346
1347			fn prerelease(&self) -> Option<bool> {
1348				self.get_str("Prerelease").map(|v| {
1349					v.parse().expect("expected parachain prerelease value to be true/false")
1350				})
1351			}
1352		}
1353
1354		impl<T: EnumProperty> Repository for T {
1355			fn repository(&self) -> &str {
1356				self.get_str("Repository").expect("expected specification of `Repository` url")
1357			}
1358
1359			fn tag_pattern(&self) -> Option<&str> {
1360				self.get_str("TagPattern")
1361			}
1362		}
1363	}
1364
1365	#[cfg(test)]
1366	mod tests {
1367		use super::enums::{Repository, Source};
1368		use strum_macros::{EnumProperty, VariantArray};
1369
1370		#[derive(EnumProperty, VariantArray)]
1371		pub(super) enum Chain {
1372			#[strum(props(
1373				Repository = "https://github.com/paritytech/polkadot-sdk",
1374				Binary = "polkadot",
1375				Prerelease = "false",
1376				Fallback = "v1.12.0",
1377				TagPattern = "polkadot-{version}"
1378			))]
1379			Polkadot,
1380			#[strum(props(Repository = "https://github.com/r0gue-io/fallback", Fallback = "v1.0"))]
1381			Fallback,
1382		}
1383
1384		#[test]
1385		fn binary_works() {
1386			assert_eq!("polkadot", Chain::Polkadot.binary())
1387		}
1388
1389		#[test]
1390		fn fallback_works() {
1391			assert_eq!("v1.12.0", Chain::Polkadot.fallback())
1392		}
1393
1394		#[test]
1395		fn prerelease_works() {
1396			assert!(!Chain::Polkadot.prerelease().unwrap())
1397		}
1398
1399		#[test]
1400		fn repository_works() {
1401			assert_eq!("https://github.com/paritytech/polkadot-sdk", Chain::Polkadot.repository())
1402		}
1403
1404		#[test]
1405		fn tag_pattern_works() {
1406			assert_eq!("polkadot-{version}", Chain::Polkadot.tag_pattern().unwrap())
1407		}
1408	}
1409}
1410
1411/// Filters which can be used when resolving a binary.
1412pub mod filters {
1413	/// A filter which ensures a candidate file name starts with a prefix.
1414	///
1415	/// # Arguments
1416	/// * `candidate` - the candidate to be evaluated.
1417	/// * `prefix` - the specified prefix.
1418	pub fn prefix(candidate: &str, prefix: &str) -> bool {
1419		candidate.starts_with(prefix) &&
1420			// Ignore any known related `polkadot`-prefixed binaries when `polkadot` only.
1421			(prefix != "polkadot" ||
1422				!["polkadot-execute-worker", "polkadot-prepare-worker", "polkadot-parachain"]
1423					.iter()
1424					.any(|i| candidate.starts_with(i)))
1425	}
1426
1427	#[cfg(test)]
1428	pub(crate) fn polkadot(file: &str) -> bool {
1429		prefix(file, "polkadot")
1430	}
1431}