use crate::error::{Error, Result, error_fn};
use crate::mason::http::download_bytes as http_download_bytes;
use crate::mason::install::join_relative_path;
use crate::mason::platform::MasonPlatform;
use crate::mason::registry::{MasonAsset, MasonAssetBin, MasonDownload, MasonPackage, OneOrMany};
use crate::mason::template::TemplateContext;
use flate2::read::GzDecoder;
use reqwest::blocking::Client;
use std::collections::BTreeMap;
use std::fs;
use std::io::{Cursor, Read};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use tar::Archive;
use zip::ZipArchive;
const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
const MAX_DECOMPRESSED_BYTES: u64 = 512 * 1024 * 1024;
pub(super) struct RenderedAssetData {
bin: Option<String>,
pub(super) file: String,
ext: Option<String>,
named_bins: BTreeMap<String, String>,
}
impl RenderedAssetData {
pub(super) fn template_context<'a>(&'a self, version: &'a str) -> TemplateContext<'a> {
TemplateContext {
version,
source_asset_bin: self.bin.as_deref(),
source_asset_file: Some(&self.file),
source_asset_ext: self.ext.as_deref(),
source_download_bin: None,
source_download_config: None,
source_download_man: None,
source_asset_named_bins: self.named_bins.clone(),
}
}
}
pub(super) struct RenderedDownloadData {
bin: Option<String>,
config: Option<String>,
man: Option<String>,
}
impl RenderedDownloadData {
pub(super) fn template_context<'a>(&'a self, version: &'a str) -> TemplateContext<'a> {
TemplateContext {
version,
source_asset_bin: None,
source_asset_file: None,
source_asset_ext: None,
source_download_bin: self.bin.as_deref(),
source_download_config: self.config.as_deref(),
source_download_man: self.man.as_deref(),
source_asset_named_bins: BTreeMap::new(),
}
}
}
pub(super) fn render_asset_data(
asset: &MasonAsset,
version: &str,
program: &str,
package_name: &str,
) -> Result<RenderedAssetData> {
let base_context = TemplateContext {
version,
source_asset_bin: None,
source_asset_file: None,
source_asset_ext: None,
source_download_bin: None,
source_download_config: None,
source_download_man: None,
source_asset_named_bins: BTreeMap::new(),
};
let named_bins = asset
.bin
.as_ref()
.and_then(MasonAssetBin::as_map)
.map(|bins| {
bins.iter()
.map(|(name, value)| (name.clone(), base_context.render(value)))
.collect::<BTreeMap<_, _>>()
})
.unwrap_or_default();
let bin = asset
.bin
.as_ref()
.and_then(MasonAssetBin::as_single)
.map(|value| base_context.render(value))
.or_else(|| named_bins.get(program).cloned());
let ext = asset.ext.as_deref().map(|value| base_context.render(value));
let file_context = TemplateContext {
version,
source_asset_bin: bin.as_deref(),
source_asset_file: None,
source_asset_ext: ext.as_deref(),
source_download_bin: None,
source_download_config: None,
source_download_man: None,
source_asset_named_bins: named_bins.clone(),
};
let file = file_context.render(asset.file.as_slice().first().ok_or_else(|| {
Error::unexpected(format!(
"cannot install {package_name} because its GitHub asset file list is empty"
))
})?);
Ok(RenderedAssetData {
bin,
file,
ext,
named_bins,
})
}
pub(super) fn render_download_data(
download: &MasonDownload,
version: &str,
) -> RenderedDownloadData {
let base_context = TemplateContext {
version,
source_asset_bin: None,
source_asset_file: None,
source_asset_ext: None,
source_download_bin: None,
source_download_config: None,
source_download_man: None,
source_asset_named_bins: BTreeMap::new(),
};
let bin = download
.bin
.as_deref()
.map(|value| base_context.render(value));
let field_context = TemplateContext {
version,
source_asset_bin: None,
source_asset_file: None,
source_asset_ext: None,
source_download_bin: bin.as_deref(),
source_download_config: None,
source_download_man: None,
source_asset_named_bins: BTreeMap::new(),
};
let config = download
.config
.as_deref()
.map(|value| field_context.render(value));
let man = download
.man
.as_deref()
.map(|value| field_context.render(value));
RenderedDownloadData { bin, config, man }
}
pub(super) fn select_asset<'a>(
package: &'a MasonPackage,
platform: &MasonPlatform,
) -> Result<&'a MasonAsset> {
package
.source
.assets()
.iter()
.find(|asset| matches_platform(asset.target.as_ref(), platform))
.ok_or_else(|| {
Error::unexpected(format!("cannot install {} on this platform", package.name))
})
}
pub(super) fn select_download<'a>(
package: &'a MasonPackage,
platform: &MasonPlatform,
) -> Result<&'a MasonDownload> {
package
.source
.downloads()
.iter()
.find(|download| matches_platform(download.target.as_ref(), platform))
.ok_or_else(|| {
Error::unexpected(format!("cannot install {} on this platform", package.name))
})
}
fn matches_platform(targets: Option<&OneOrMany<String>>, platform: &MasonPlatform) -> bool {
targets.is_none_or(|targets| {
targets
.as_slice()
.iter()
.any(|target| platform.matches(target))
})
}
pub(super) fn ensure_command_success(
output: &std::process::Output,
package: &MasonPackage,
command_name: &str,
) -> Result<()> {
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = command_failure_detail(&stderr);
Err(Error::unexpected(format!(
"cannot install {} because {} failed: {detail}",
package.name, command_name
)))
}
pub(super) fn command_failure_detail(stderr: &str) -> &str {
stderr
.lines()
.map(str::trim)
.rfind(|line| !line.is_empty())
.unwrap_or("command failed")
}
pub(super) fn http_client() -> Result<Client> {
Client::builder()
.user_agent(USER_AGENT)
.build()
.map_err(error_fn!(Error::network, "failed to create HTTP client"))
}
pub(super) fn download_bytes(
client: &Client,
url: &str,
package: &MasonPackage,
) -> Result<Vec<u8>> {
http_download_bytes(
client,
url,
&format!("failed to download {}", package.name),
&format!("failed to download {}", package.name),
&format!("failed to read download for {}", package.name),
)
}
pub(super) fn install_downloaded_artifact(
root: &Path,
relative_name: &str,
bytes: &[u8],
) -> Result<()> {
crate::fs::create_dir_all(root)?;
let relative_name_lower = relative_name.to_ascii_lowercase();
let extension = Path::new(relative_name)
.extension()
.and_then(|value| value.to_str());
if relative_name_lower.ends_with(".tar.gz") {
extract_tar_gz(root, bytes)
} else if relative_name_lower.ends_with(".tar.xz") {
extract_tar_xz(root, bytes)
} else if extension.is_some_and(|value| value.eq_ignore_ascii_case("zip")) {
extract_zip(root, bytes)
} else if extension.is_some_and(|value| value.eq_ignore_ascii_case("gz")) {
let target = join_relative_path(root, &relative_name[..relative_name.len() - 3])?;
write_gzip_file(&target, bytes)
} else {
let path = join_relative_path(root, relative_name)?;
write_file(&path, bytes)
}
}
fn extract_tar_gz(root: &Path, bytes: &[u8]) -> Result<()> {
let reader = GzDecoder::new(Cursor::new(bytes));
unpack_tar(root, reader)
}
fn extract_tar_xz(root: &Path, bytes: &[u8]) -> Result<()> {
let mut decompressed = BoundedWriter::new(MAX_DECOMPRESSED_BYTES);
lzma_rs::xz_decompress(&mut Cursor::new(bytes), &mut decompressed).map_err(
format_root_error("failed to decompress downloaded xz archive in", root),
)?;
unpack_tar(root, Cursor::new(decompressed.into_inner()))
}
struct BoundedWriter {
buffer: Vec<u8>,
limit: u64,
}
impl BoundedWriter {
fn new(limit: u64) -> Self {
Self {
buffer: Vec::new(),
limit,
}
}
fn into_inner(self) -> Vec<u8> {
self.buffer
}
}
impl std::io::Write for BoundedWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
if self.buffer.len() as u64 + data.len() as u64 > self.limit {
return Err(std::io::Error::other(
"decompressed archive exceeds size limit",
));
}
self.buffer.extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn unpack_tar(root: &Path, reader: impl Read) -> Result<()> {
let mut archive = Archive::new(reader);
for entry in archive.entries().map_err(format_root_error(
"failed to open downloaded tar archive in",
root,
))? {
let mut entry = entry.map_err(format_root_error(
"failed to read downloaded tar archive in",
root,
))?;
let entry_path = entry
.path()
.map_err(format_root_error("failed to read tar entry path in", root))?;
let output_path = join_relative_path(root, &entry_path.to_string_lossy())?;
if entry.header().entry_type().is_dir() {
crate::fs::create_dir_all(&output_path)?;
continue;
}
ensure_decompressed_size_limit(
entry.size(),
&format!("tar entry {}", output_path.display()),
)?;
if let Some(parent) = output_path.parent() {
crate::fs::create_dir_all(parent)?;
}
entry.unpack(&output_path).map_err(error_fn!(
Error::unexpected,
"failed to extract {}",
output_path.display()
))?;
}
Ok(())
}
fn extract_zip(root: &Path, bytes: &[u8]) -> Result<()> {
let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(format_root_error(
"failed to open downloaded zip archive in",
root,
))?;
for index in 0..archive.len() {
let mut file = archive.by_index(index).map_err(format_root_error(
"failed to read downloaded zip archive in",
root,
))?;
let Some(name) = file.enclosed_name() else {
return Err(Error::network(format!(
"downloaded zip archive contains unsafe paths for {}",
root.display()
)));
};
let output_path = root.join(name);
if file.is_dir() {
crate::fs::create_dir_all(&output_path)?;
continue;
}
ensure_decompressed_size_limit(
file.size(),
&format!("zip entry {}", output_path.display()),
)?;
if let Some(parent) = output_path.parent() {
crate::fs::create_dir_all(parent)?;
}
let mut output = fs::File::create(&output_path).map_err(error_fn!(
Error::unexpected,
"failed to create {}",
output_path.display()
))?;
std::io::copy(&mut file, &mut output).map_err(error_fn!(
Error::unexpected,
"failed to extract {}",
output_path.display()
))?;
#[cfg(unix)]
if let Some(mode) = file.unix_mode() {
fs::set_permissions(
&output_path,
fs::Permissions::from_mode(owner_writable_mode(mode)),
)
.map_err(error_fn!(
Error::unexpected,
"failed to set permissions on {}",
output_path.display()
))?;
}
}
Ok(())
}
fn write_gzip_file(path: &Path, bytes: &[u8]) -> Result<()> {
let mut output = Vec::new();
GzDecoder::new(Cursor::new(bytes))
.take(MAX_DECOMPRESSED_BYTES + 1)
.read_to_end(&mut output)
.map_err(error_fn!(
Error::network,
"failed to unpack {}",
path.display()
))?;
ensure_decompressed_size_limit(output.len() as u64, &path.display().to_string())?;
write_file(path, &output)
}
fn format_root_error<'a, E: std::fmt::Display>(
action: &'static str,
root: &'a Path,
) -> impl FnOnce(E) -> Error + 'a {
move |error| Error::network(format!("{action} {}: {error}", root.display()))
}
fn ensure_decompressed_size_limit(size: u64, path: &str) -> Result<()> {
if size > MAX_DECOMPRESSED_BYTES {
Err(Error::network(format!(
"refusing to unpack {path} because it expands beyond {MAX_DECOMPRESSED_BYTES} bytes"
)))
} else {
Ok(())
}
}
#[cfg(unix)]
fn owner_writable_mode(mode: u32) -> u32 {
mode & !0o022
}
fn write_file(path: &Path, bytes: &[u8]) -> Result<()> {
let Some(parent) = path.parent() else {
return Err(Error::unexpected(format!(
"failed to determine parent directory for {}",
path.display()
)));
};
crate::fs::create_dir_all(parent)?;
crate::fs::write(path, bytes)
}
pub(super) fn parse_archive_file_spec(file: &str) -> (&str, Option<&str>) {
match file.split_once(':') {
Some((archive, directory)) => (archive, Some(directory.trim_end_matches('/'))),
None => (file, None),
}
}