pop_common/sourcing/
mod.rs

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