From 2a0f53e107ca3d60744ab447e5ecbd43227c46f8 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Wed, 9 Apr 2025 18:44:26 +0200 Subject: [PATCH] feat: Downloading entire game works now --- Cargo.lock | 45 ++- Cargo.toml | 6 +- packages/core/Cargo.toml | 6 +- packages/core/examples/download_minecraft.rs | 4 +- packages/core/src/api/game/metadata.rs | 287 ++++++++++++++++++- packages/core/src/error.rs | 2 + packages/core/src/lib.rs | 1 + packages/core/src/store/ingress.rs | 3 + packages/core/src/store/metadata.rs | 8 +- packages/core/src/utils/crypto.rs | 111 ++++++- packages/core/src/utils/ext.rs | 48 ++++ packages/core/src/utils/http.rs | 22 +- packages/core/src/utils/io.rs | 136 ++++++++- packages/core/src/utils/mod.rs | 1 + 14 files changed, 607 insertions(+), 73 deletions(-) create mode 100644 packages/core/src/utils/ext.rs diff --git a/Cargo.lock b/Cargo.lock index 14939e2c..2111ea36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2453,12 +2453,6 @@ dependencies = [ "digest", ] -[[package]] -name = "md5" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" - [[package]] name = "memchr" version = "2.7.4" @@ -2942,13 +2936,14 @@ dependencies = [ "base64 0.22.1", "bytes", "chrono", + "digest", "dirs", "discord-rich-presence", "dunce", "futures", "indicatif", "interpulse", - "md5", + "md-5", "murmur2", "onelauncher_entity", "onelauncher_gamemode", @@ -2958,13 +2953,14 @@ dependencies = [ "rand 0.8.5", "regex", "reqwest", + "sanitize-filename", "sea-orm", "serde", "serde_ini", "serde_json", "serde_repr", "serde_with", - "sha1_smol", + "sha1", "sha2", "specta", "sysinfo", @@ -3730,14 +3726,14 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.6" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.7", - "regex-syntax 0.8.4", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", ] [[package]] @@ -3751,13 +3747,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.4", + "regex-syntax 0.8.5", ] [[package]] @@ -3768,9 +3764,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "rend" @@ -4007,6 +4003,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + [[package]] name = "schemars" version = "0.8.21" @@ -4404,12 +4409,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - [[package]] name = "sha2" version = "0.10.8" diff --git a/Cargo.toml b/Cargo.toml index af409da9..e3a4d68b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -117,12 +117,14 @@ async_zip = { version = "0.0.17", features = [ "full" ] } async-compression = { version = "0.4.12", features = [ "flate2", "tokio" ] } flate2 = { version = "1.0" } bytes = { version = "1.7" } +sanitize-filename = { version = "0.6" } # crypto -sha1_smol = { version = "1.0", features = [ "std" ] } +digest = { version = "0.10" } +sha1 = { version = "0.10" } sha2 = { version = "0.10" } +md-5 = { version = "0.10" } murmur2 = { version = "0.1.0" } -md5 = { version = "0.7.0" } # db sea-orm = { version = "1.1", features = [ diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index fd9c1e35..27a56bce 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -70,10 +70,11 @@ paste = { workspace = true } discord-rich-presence = { workspace = true } # crypto -sha1_smol = { workspace = true } +digest = { workspace = true } +sha1 = { workspace = true } sha2 = { workspace = true } murmur2 = { workspace = true } -md5 = { workspace = true } +md-5 = { workspace = true } # io zip = { workspace = true } @@ -84,6 +85,7 @@ base64 = { workspace = true } async-compression = { workspace = true } dunce = { workspace = true } tempfile = { workspace = true } +sanitize-filename = { workspace = true } # features[tauri] tauri = { workspace = true, optional = true } diff --git a/packages/core/examples/download_minecraft.rs b/packages/core/examples/download_minecraft.rs index f8b63e4a..dec57156 100644 --- a/packages/core/examples/download_minecraft.rs +++ b/packages/core/examples/download_minecraft.rs @@ -10,10 +10,10 @@ async fn main() -> LauncherResult<()> { let mut metadata = state.metadata.write().await; let versions = metadata.get_vanilla(GameLoader::Vanilla).await?; - let version = versions.versions.iter().find(|v| v.id == "1.8.9").ok_or_else(|| anyhow!("couldn't find 1.8.9 in version list"))?; + let version = versions.versions.iter().find(|v| v.id == "1.20.4").ok_or_else(|| anyhow!("couldn't find 1.8.9 in version list"))?; let version_info = download_version_info(version, None, None, Some(true)).await?; - download_minecraft_ingressed(&version_info, Some(true)).await?; + download_minecraft_ingressed(&version_info, "x86", Some(true)).await?; Ok(()) } \ No newline at end of file diff --git a/packages/core/src/api/game/metadata.rs b/packages/core/src/api/game/metadata.rs index 12ff8bbe..bbe834a6 100644 --- a/packages/core/src/api/game/metadata.rs +++ b/packages/core/src/api/game/metadata.rs @@ -1,17 +1,25 @@ +use std::path::PathBuf; + use futures::{StreamExt, stream}; -use interpulse::api::minecraft::{AssetsIndex, Version, VersionInfo}; +use interpulse::api::minecraft::{AssetsIndex, DownloadType, Library, Os, OsRule, Rule, RuleAction, Version, VersionInfo}; use interpulse::api::modded::LoaderVersion; +use regex::Regex; use reqwest::Method; use crate::api::ingress::{init_ingress, send_ingress_ref_opt}; -use crate::error::LauncherResult; +use crate::error::{LauncherError, LauncherResult}; use crate::store::ingress::{IngressRef, IngressType}; use crate::store::{Core, Dirs}; use crate::utils::crypto::HashAlgorithm; -use crate::utils::http::{self, fetch_json_advanced}; +use crate::utils::ext::OsExt; +use crate::utils::http; use crate::utils::io; -pub async fn download_minecraft_ingressed(version: &VersionInfo, force: Option) -> LauncherResult<()> { +pub async fn download_minecraft_ingressed( + version: &VersionInfo, + java_arch: &str, + force: Option +) -> LauncherResult<()> { let id = init_ingress( IngressType::MinecraftDownload, &format!("Downloading Minecraft {}", version.id), @@ -21,20 +29,27 @@ pub async fn download_minecraft_ingressed(version: &VersionInfo, force: Option>, force: Option) -> LauncherResult<()> { +pub async fn download_minecraft( + version: &VersionInfo, + java_arch: &str, + ingress_ref: Option<&IngressRef<'_>>, + force: Option +) -> LauncherResult<()> { - const TASKS: f64 = 2.0; + const TASKS: f64 = 4.0; let ingress_ref = ingress_ref.map(|i| i.with_increment(i.increment_by / TASKS)); let ingress_ref = ingress_ref.as_ref(); let asset_index = download_assets_index(version, ingress_ref, force).await?; tokio::try_join! { - download_assets(version.assets == "legacy", &asset_index, ingress_ref, force) + download_assets(version.assets == "legacy", &asset_index, ingress_ref, force), + download_client(version, ingress_ref, force), + download_libraries(&version.id, &version.libraries, java_arch, ingress_ref, force), }?; Ok(()) @@ -48,6 +63,7 @@ pub async fn download_version_info( ingress_ref: Option<&IngressRef<'_>>, force: Option, ) -> LauncherResult { + tracing::debug!("loading version info for version {}", version.id); let version_id = loader.map_or(version.id.clone(), |it| format!("{}-{}", version.id, it.id)); let ingress_ref = ingress_ref.map(|i| i.with_increment(i.increment_by / 3.33)); @@ -70,11 +86,11 @@ pub async fn download_version_info( ); let mut info = - fetch_json_advanced(Method::GET, &version.url, None, None, None, ingress_ref).await?; + http::fetch_json_advanced(Method::GET, &version.url, None, None, None, ingress_ref).await?; if let Some(loader) = loader { let partial: interpulse::api::modded::PartialVersionInfo = - fetch_json_advanced(Method::GET, &loader.url, None, None, None, ingress_ref) + http::fetch_json_advanced(Method::GET, &loader.url, None, None, None, ingress_ref) .await?; info = interpulse::api::modded::merge_partial_version(partial, info); @@ -100,6 +116,7 @@ pub async fn download_assets_index( ingress_ref: Option<&IngressRef<'_>>, force: Option, ) -> LauncherResult { + tracing::debug!("loading assets index for version {}", version.id); let path = Dirs::get_assets_index_dir() .await? .join(version.asset_index.id.clone()); @@ -115,7 +132,7 @@ pub async fn download_assets_index( return Ok(data); } Err(err) => tracing::error!( - "failed to read assets index from cache: {err:?}, downloading again", + "failed to read assets index from cache: {err:?}, downloading...", ), } } @@ -135,13 +152,15 @@ pub async fn download_assets_index( } // MARK: Assets +/// Downloads the assets for a given asset index, returns the number of failed downloads #[tracing::instrument(skip_all)] pub async fn download_assets( legacy: bool, assets_index: &AssetsIndex, ingress_ref: Option<&IngressRef<'_>>, force: Option, -) -> LauncherResult<()> { +) -> LauncherResult { + tracing::debug!("loading asssets"); let len = assets_index.objects.len(); let dir = if legacy { @@ -176,13 +195,253 @@ pub async fn download_assets( ) .await.map(|_| ()) } else { + // TODO: Possibly check hash? (not sure if this is a good idea here) Ok(()) } })) .buffer_unordered(Core::get().fetch_attempts.min(7)) .collect::>(); - requests.await; + let mut failed = 0; + for res in requests.await { + if let Err(err) = res { + tracing::error!("failed to download asset: {err:?}"); + failed += 1; + } + } - Ok(()) + tracing::debug!("loaded assets"); + Ok(failed) } + +// MARK: Client +pub async fn download_client( + version: &VersionInfo, + ingress_ref: Option<&IngressRef<'_>>, + force: Option, +) -> LauncherResult { + tracing::debug!("loading client for version {}", version.id); + let client = version.downloads + .get(&DownloadType::Client) + .ok_or_else(|| anyhow::anyhow!("no client downloads exist for {}", &version.id))?; + + let path = Dirs::get_versions_dir().await?.join(version.id.clone()).join(format!("{}.jar", version.id)); + if path.exists() && !force.unwrap_or(false) { + let calculated_hash = HashAlgorithm::Sha1 + .hash_file(&path) + .await + .map_err(|_| anyhow::anyhow!("failed to hash file"))?; + + if client.sha1 == calculated_hash { + tracing::debug!("client already exists, skipping download"); + return Ok(path); + } + + tracing::warn!("client hash mismatch, redownloading"); + } + + let ingress_ref = ingress_ref.map(|i| i.with_increment(i.increment_by / 2.0)); + let ingress_ref = ingress_ref.as_ref(); + + http::download(Method::GET, &client.url, &path, Some((HashAlgorithm::Sha1, &client.sha1)), ingress_ref).await?; + + Ok(path) +} + +// MARK: Libraries +/// Downloads the libraries and returns the number of failed downloads +pub async fn download_libraries( + version: &str, + libraries: &[Library], + java_arch: &str, + ingress_ref: Option<&IngressRef<'_>>, + force: Option, +) -> LauncherResult { + tracing::debug!("loading libraries for version {}", version); + + let lib_dir = Dirs::get_libraries_dir().await?; + let natives_dir = Dirs::get_natives_dir().await?; + + io::create_dir_all(&lib_dir).await?; + io::create_dir_all(&natives_dir).await?; + + let num_files = libraries.len(); + + let ingress_ref = ingress_ref.map(|i| i.with_increment(i.increment_by / num_files as f64)); + let ingress_ref = ingress_ref.as_ref(); + + let requests = stream::iter(libraries.iter().map(|lib| async { + if let Some(rules) = &lib.rules { + if !validate_rules(rules, java_arch, lib.natives.is_some()) { + tracing::debug!("skipping library {} due to rules", lib.name); + return Ok::<(), LauncherError>(()); + } + } + + if !lib.downloadable { + tracing::debug!("skipping library {} due to downloadability", lib.name); + return Ok(()); + } + + tokio::try_join! { + async { + let artifact_path = interpulse::utils::get_path_from_artifact(&lib.name)?; + let path = lib_dir.join(&artifact_path); + + if path.exists() && !force.unwrap_or(false) { + return Ok(()); + } + + if let Some(interpulse::api::minecraft::LibraryDownloads { + artifact: Some(ref artifact), .. + }) = lib.downloads { + if !artifact.url.is_empty() { + http::download( + Method::GET, + &artifact.url, + &path, + Some((HashAlgorithm::Sha1, &artifact.sha1)), + ingress_ref, + ).await?; + + tracing::trace!("fetched library {} to path {:?}", &lib.name, &path); + return Ok::<_, LauncherError>(()); + } + } + + let url = [lib.url.as_deref().unwrap_or("https://libraries.minecraft.net/"), &artifact_path].concat(); + http::download( + Method::GET, + &url, + &path, + None, + ingress_ref, + ).await?; + + tracing::trace!("fetched library {} to path {:?}", &lib.name, &path); + Ok::<_, LauncherError>(()) + }, + async { + if let Some((os_key, classifiers)) = None.or_else(|| Some(( + lib.natives.as_ref()?.get(&Os::native_arch(java_arch))?, + lib.downloads.as_ref()?.classifiers.as_ref()? + ))) { + let parsed = os_key.replace("${arch}", crate::constants::ARCH_WIDTH); + if let Some(native) = classifiers.get(&parsed) { + tracing::trace!("found native library {}", &lib.name); + let data = http::fetch_advanced( + Method::GET, + &native.url, + None, + Some((HashAlgorithm::Sha1, &native.sha1)), + None, + ingress_ref, + ).await?; + + let dest = natives_dir.join(version); + io::unzip_bytes(data.to_vec(), &dest).await?; + tracing::trace!("extracted native {} to path {:?}", &lib.name, &dest); + } + } + + Ok(()) + } + }?; + + Ok(()) + })).buffer_unordered(7).collect::>(); + + let mut failed = 0; + for res in requests.await { + if let Err(err) = res { + tracing::error!("failed to download asset: {err:?}"); + failed += 1; + } + } + + Ok(failed) +} + +// MARK: Rules +#[tracing::instrument(level = "debug")] +fn validate_rules(rules: &[Rule], java_version: &str, updated: bool) -> bool { + let mut rule = rules + .iter() + .map(|r| validate_rule(r, java_version, updated)) + .collect::>>(); + if rules + .iter() + .all(|r| matches!(r.action, RuleAction::Disallow)) + { + rule.push(Some(true)); + } + + !(rule.iter().any(|r| r == &Some(false)) || rule.iter().all(Option::is_none)) +} + +/// Parses a Minecraft library feature or OS rule. +/// Is disallowed -> Don't include it +/// Is not allowed -> Don't include it +/// Is allowed -> Include it +#[tracing::instrument(level = "debug")] +fn validate_rule(rule: &Rule, java_version: &str, updated: bool) -> Option { + let result = match rule { + Rule { + os: Some(os), .. + } => validate_os_rule(os, java_version, updated), + Rule { + features: Some(features), + .. + } => { + !features.is_demo_user.unwrap_or(true) + || features.has_custom_resolution.unwrap_or(false) + || !features.has_quick_plays_support.unwrap_or(true) + || !features.is_quick_play_multiplayer.unwrap_or(true) + || !features.is_quick_play_realms.unwrap_or(true) + || !features.is_quick_play_singleplayer.unwrap_or(true) + } + _ => return Some(true), + }; + + match rule.action { + RuleAction::Allow => { + if result { + Some(true) + } else { + Some(false) + } + } + RuleAction::Disallow => { + if result { + Some(false) + } else { + None + } + } + } +} + +#[must_use] +fn validate_os_rule(rule: &OsRule, java_arch: &str, updated: bool) -> bool { + let mut rule_match = true; + + if let Some(ref arch) = rule.arch { + rule_match &= !matches!(arch.as_str(), "x86" | "arm"); + } + + if let Some(name) = &rule.name { + if updated && (name != &Os::LinuxArm64 || name != &Os::LinuxArm32) { + rule_match &= &Os::native() == name || &Os::native_arch(java_arch) == name; + } else { + rule_match &= &Os::native_arch(java_arch) == name; + } + } + + if let Some(version) = &rule.version { + if let Ok(regex) = Regex::new(version.as_str()) { + rule_match &= regex.is_match(&sysinfo::System::os_version().unwrap_or_default()); + } + } + + rule_match +} \ No newline at end of file diff --git a/packages/core/src/error.rs b/packages/core/src/error.rs index eb95c3e1..059051d5 100644 --- a/packages/core/src/error.rs +++ b/packages/core/src/error.rs @@ -25,6 +25,8 @@ pub enum LauncherError { DbError(#[from] sea_orm::DbErr), #[error(transparent)] ReqwestError(#[from] reqwest::Error), + #[error(transparent)] + InterpulseError(#[from] interpulse::Error), #[error("couldn't acquire semaphore: {0}")] SemaphoreError(#[from] tokio::sync::AcquireError), diff --git a/packages/core/src/lib.rs b/packages/core/src/lib.rs index ad005a37..93aa5d42 100644 --- a/packages/core/src/lib.rs +++ b/packages/core/src/lib.rs @@ -1,4 +1,5 @@ #![feature(let_chains)] +#![feature(slice_as_array)] use api::proxy::LauncherProxy; use store::{proxy::ProxyState, semaphore::SemaphoreStore, Core, CoreOptions, Dirs, State}; diff --git a/packages/core/src/store/ingress.rs b/packages/core/src/store/ingress.rs index ce7ab49a..2cc254e5 100644 --- a/packages/core/src/store/ingress.rs +++ b/packages/core/src/store/ingress.rs @@ -99,6 +99,9 @@ pub struct IngressRef<'a> { pub increment_by: f64, } +unsafe impl Send for IngressRef<'_> {} +unsafe impl Sync for IngressRef<'_> {} + impl<'a> IngressRef<'a> { #[must_use] pub const fn new(ingress_id: &'a IngressId, increment_by: f64) -> Self { diff --git a/packages/core/src/store/metadata.rs b/packages/core/src/store/metadata.rs index cdd33cd3..a8a6946b 100644 --- a/packages/core/src/store/metadata.rs +++ b/packages/core/src/store/metadata.rs @@ -177,21 +177,19 @@ impl Metadata { ($(($var:tt, $is_modded:tt)),*) => { let ($($var),*) = tokio::join!( $( - tokio::spawn(async move { + async move { if let Ok(loader) = GameLoader::from_str(stringify!(minecraft)) { fetch_manifest!(loader, $is_modded).map_err(|e| $crate::send_error!("couldn't fetch {loader} manifest: {e}")).ok() } else { tracing::error!("failed to fetch {} manifest", stringify!($var)); None } - }), + }, )* ); $( - if let Ok(data) = $var { - self.inner.$var = data; - } + self.inner.$var = $var; )* }; } diff --git a/packages/core/src/utils/crypto.rs b/packages/core/src/utils/crypto.rs index 1de79b5f..724a1154 100644 --- a/packages/core/src/utils/crypto.rs +++ b/packages/core/src/utils/crypto.rs @@ -1,4 +1,11 @@ -use std::{fmt::Display, str::FromStr}; +use std::fmt::{Display, Write}; +use std::io::Read; +use std::str::FromStr; + +use digest::DynDigest; +use tokio::io::AsyncReadExt; + +use super::io::IOError; #[derive(Debug, thiserror::Error)] pub enum CryptoError { @@ -9,20 +16,89 @@ pub enum CryptoError { actual: String, }, #[error("invalid hash algorithm")] - InvalidAlgorithm + InvalidAlgorithm, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum HashAlgorithm { Sha1, + Sha256, + Md5, } +const BUFFER_SIZE: usize = 1024 * 256; // 16 KiB + impl HashAlgorithm { - #[must_use] - pub fn hash(&self, data: &[u8]) -> String { - match self { - Self::Sha1 => sha1_smol::Sha1::from(data).digest().to_string(), - } + pub fn hasher_sync(&self, closure: F) -> Result + where + F: Fn(&mut (dyn DynDigest)) -> Result<(), IOError>, + { + let mut hasher = get_inner(self); + closure(&mut *hasher)?; + Ok(to_hex(&hasher.finalize())) + } + + pub async fn hasher(&self, closure: F) -> Result + where + F: AsyncFn(&mut (dyn DynDigest)) -> Result<(), IOError>, + { + let mut hasher = get_inner(self); + closure(&mut *hasher).await?; + Ok(to_hex(&hasher.finalize())) + } + + pub async fn hash(&self, data: &[u8]) -> Result { + self.hasher(async move |hasher| { + hasher.update(data); + Ok(()) + }).await + } + + pub fn hash_sync(&self, data: &[u8]) -> Result { + self.hasher_sync(move |hasher| { + hasher.update(data); + Ok(()) + }) + } + + pub async fn hash_file(&self, path: impl AsRef) -> Result { + self.hasher(async |hasher: &mut dyn DynDigest| { + let path = path.as_ref(); + let file = tokio::fs::File::open(path).await?; + let mut reader = tokio::io::BufReader::new(file); + + let mut buffer = vec![0; BUFFER_SIZE].into_boxed_slice(); + loop { + let bytes_read = reader.read(&mut buffer).await?; + if bytes_read == 0 { + break; + } + + hasher.update(&buffer[..bytes_read]); + } + + Ok(()) + }).await + } + + pub fn hash_file_sync(&self, path: impl AsRef) -> Result { + self.hasher_sync(|hasher: &mut dyn DynDigest| { + let path = path.as_ref(); + let file = std::fs::File::open(path)?; + let mut reader = std::io::BufReader::new(file); + + let mut buffer = vec![0; BUFFER_SIZE].into_boxed_slice(); + loop { + let bytes_read = reader.read(&mut buffer)?; + if bytes_read == 0 { + break; + } + + hasher.update(&buffer[..bytes_read]); + } + + Ok(()) + }) } } @@ -30,6 +106,8 @@ impl Display for HashAlgorithm { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Sha1 => write!(f, "sha1"), + Self::Sha256 => write!(f, "sha256"), + Self::Md5 => write!(f, "md5"), } } } @@ -39,8 +117,25 @@ impl FromStr for HashAlgorithm { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { - "sha1" => Ok(Self::Sha1), + "sha1" | "sha-1" => Ok(Self::Sha1), + "sha256" | "sha-256" => Ok(Self::Sha256), + "md5" | "md-5" => Ok(Self::Md5), _ => Err(CryptoError::InvalidAlgorithm), } } +} + +fn get_inner(algorithm: &HashAlgorithm) -> Box { + match algorithm { + HashAlgorithm::Sha1 => Box::new(sha1::Sha1::default()), + HashAlgorithm::Sha256 => Box::new(sha2::Sha256::default()), + HashAlgorithm::Md5 => Box::new(md5::Md5::default()) + } +} + +fn to_hex(data: &[u8]) -> String { + data.iter().fold(String::new(), |mut output, b| { + let _ = write!(output, "{b:02x}"); + output + }) } \ No newline at end of file diff --git a/packages/core/src/utils/ext.rs b/packages/core/src/utils/ext.rs new file mode 100644 index 00000000..9c078ecd --- /dev/null +++ b/packages/core/src/utils/ext.rs @@ -0,0 +1,48 @@ +use interpulse::api::minecraft::Os; + +/// An extension of [`Os`] for utilities on converting the current system's operating system +/// into an [`Os`] structure for simple management. +pub trait OsExt { + /// Get the [`Os`] of the current system + fn native() -> Self; + + /// Get the [`Os`] of the current system along with it's arch from the `java_arch`. + fn native_arch(java_arch: &str) -> Self; +} + +impl OsExt for Os { + fn native_arch(java_arch: &str) -> Self { + if std::env::consts::OS == "windows" { + if java_arch == "aarch64" { + Self::WindowsArm64 + } else { + Self::Windows + } + } else if std::env::consts::OS == "linux" { + if java_arch == "aarch64" { + Self::LinuxArm64 + } else if java_arch == "arm" { + Self::LinuxArm32 + } else { + Self::Linux + } + } else if std::env::consts::OS == "macos" { + if java_arch == "aarch64" { + Self::OsxArm64 + } else { + Self::Osx + } + } else { + Self::Unknown + } + } + + fn native() -> Self { + match std::env::consts::OS { + "windows" => Self::Windows, + "macos" => Self::Osx, + "linux" => Self::Linux, + _ => Self::Unknown, + } + } +} \ No newline at end of file diff --git a/packages/core/src/utils/http.rs b/packages/core/src/utils/http.rs index 8b62b48a..c646ad6b 100644 --- a/packages/core/src/utils/http.rs +++ b/packages/core/src/utils/http.rs @@ -123,15 +123,21 @@ pub async fn fetch_advanced( if let Some((ref algorithm, expected_hash)) = hash { let expected_hash = expected_hash.to_string(); - let calculated_hash = algorithm.hash(&bytes); - - if *calculated_hash != expected_hash { - return Err(CryptoError::InvalidHash { - algorithm: algorithm.clone(), - expected: expected_hash, - actual: calculated_hash, + match algorithm.hash(&bytes).await { + Ok(calculated_hash) => { + if *calculated_hash != expected_hash { + return Err(CryptoError::InvalidHash { + algorithm: algorithm.clone(), + expected: expected_hash, + actual: calculated_hash, + } + .into()); + } + }, + Err(err) => { + tracing::error!("failed to calculate hash for {url}: {err}"); + return Err(err.into()); } - .into()); } } diff --git a/packages/core/src/utils/io.rs b/packages/core/src/utils/io.rs index dc0d70c5..513eb60d 100644 --- a/packages/core/src/utils/io.rs +++ b/packages/core/src/utils/io.rs @@ -1,13 +1,16 @@ +use std::path::PathBuf; + use async_compression::tokio::bufread::GzipDecoder; -use serde::{de::DeserializeOwned, Serialize}; +use serde::Serialize; +use serde::de::DeserializeOwned; use tempfile::TempDir; -use tokio::io::AsyncReadExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; /// A wrapper around generic and unhelpful [`std::io::Error`] messages. #[derive(Debug, thiserror::Error)] pub enum IOError { #[error("invalid absolute path '{0}'")] - InvalidAbsolutePath(std::path::PathBuf), + InvalidAbsolutePath(PathBuf), #[error("error acessing path: {source}, path: {path}")] IOErrorWrapper { @@ -21,6 +24,8 @@ pub enum IOError { IOError(#[from] std::io::Error), #[error("json deserialization error: {0}")] DeserializeError(#[from] serde_json::Error), + #[error(transparent)] + AsyncZipError(#[from] async_zip::error::ZipError), } impl> From<(P, std::io::Error)> for IOError { @@ -37,12 +42,12 @@ impl> From<(P, std::io::Error)> for IOError { pub struct NonUtf8PathError(pub Box); /// Attempts to parse a path taken from an environment variable. Returns `None` if the variable is not set. -pub fn env_path(name: &str) -> Option { - std::env::var_os(name).map(std::path::PathBuf::from) +pub fn env_path(name: &str) -> Option { + std::env::var_os(name).map(PathBuf::from) } /// An OS specific wrapper of [`std::fs::canonicalize`], but on Windows it outputs the most compatible form of a path instead of UNC. -pub fn canonicalize(path: impl AsRef) -> Result { +pub fn canonicalize(path: impl AsRef) -> Result { let path = path.as_ref(); dunce::canonicalize(path).map_err(|e| IOError::IOErrorWrapper { source: e, @@ -134,11 +139,13 @@ pub async fn read(path: impl AsRef) -> Result, IOError> } /// Asynchronously read a file as JSON and return the deserialized object -pub async fn read_json(path: impl AsRef) -> Result { +pub async fn read_json( + path: impl AsRef, +) -> Result { Ok(serde_json::from_slice(&read(path).await?)?) } -/// Asynchrously write to a tempfile that is then transferred to an official [`AsRef`]. +/// Asynchrously write to a file. pub async fn write( path: impl AsRef, data: impl AsRef<[u8]>, @@ -152,10 +159,26 @@ pub async fn write( }) } +/// Asynchronously write buffered data to a file, creating it if it does not exist. +pub async fn write_buf(path: impl AsRef, f: F) -> Result<(), IOError> +where + F: AsyncFnOnce(&mut tokio::io::BufWriter) -> Result<(), IOError>, +{ + let path = path.as_ref(); + let file = tokio::fs::File::create(path).await?; + let mut writer = tokio::io::BufWriter::new(file); + + f(&mut writer).await?; + + writer.flush().await?; + + Ok(()) +} + /// Asynchronously write json to a file, creating it if it does not exist. pub async fn write_json( path: impl AsRef, - data: T + data: T, ) -> Result<(), IOError> { write(path, serde_json::to_vec(&data)?).await } @@ -210,3 +233,98 @@ pub fn tempdir() -> Result { pub fn tempfile() -> Result { Ok(tempfile::tempfile()?) } + +/// Unzips a zip archive from a byte array +pub async fn unzip_bytes( + data: Vec, + dest_path: impl AsRef +) -> Result<(), IOError> { + let reader = async_zip::base::read::mem::ZipFileReader::new(data).await?; + + let entries = reader.file().entries(); + for index in 0..entries.len() { + let entry = entries.get(index).expect("expected more zip entries"); + let path = dest_path.as_ref().join(sanitize_path(entry.filename().as_str()?)); + + let is_dir = entry.dir()?; + + let mut entry_reader = reader + .reader_without_entry(index) + .await + .expect("Failed to read ZipEntry"); + + if is_dir { + create_dir_all(path).await?; + } else { + if let Some(parent) = path.parent() { + create_dir_all(parent).await?; + } + + let file = tokio::fs::File::create(&path).await?; + let mut writer = tokio::io::BufWriter::new(file); + let mut buf = vec![0; 8192]; + loop { + let n = futures::AsyncReadExt::read(&mut entry_reader, &mut buf).await?; + if n == 0 { + break; + } + writer.write_all(&buf[..n]).await?; + } + } + } + + Ok(()) +} + +/// Unzips a zip archive from a file +pub async fn unzip_file( + zip_path: impl AsRef, + dest_path: impl AsRef, +) -> Result<(), IOError> { + let zip_path = zip_path.as_ref(); + let dest_path = dest_path.as_ref(); + + let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path).await?; + let entries = reader.file().entries(); + + for index in 0..entries.len() { + let entry = entries + .get(index) + .expect("expected more zip entries"); + + let path = dest_path.join(sanitize_path(entry.filename().as_str()?)); + let is_dir = entry.dir()?; + + if is_dir { + create_dir_all(&path).await?; + } else { + if let Some(parent) = path.parent() { + create_dir_all(parent).await?; + } + + let file = tokio::fs::File::create(&path).await?; + let mut writer = tokio::io::BufWriter::new(file); + let mut entry_reader = reader.reader_without_entry(index).await?; + + let mut buf = vec![0; 8192]; + loop { + let n = futures::AsyncReadExt::read(&mut entry_reader, &mut buf).await?; + if n == 0 { + break; + } + writer.write_all(&buf[..n]).await?; + } + } + } + + Ok(()) +} + +pub fn sanitize_path(path: impl AsRef) -> PathBuf { + path.as_ref() + .to_string_lossy() + .replace('\\', "/") + .split('/') + .map(sanitize_filename::sanitize) + .collect() +} diff --git a/packages/core/src/utils/mod.rs b/packages/core/src/utils/mod.rs index 46bbf137..02250b70 100644 --- a/packages/core/src/utils/mod.rs +++ b/packages/core/src/utils/mod.rs @@ -1,3 +1,4 @@ pub mod crypto; +pub mod ext; pub mod http; pub mod io; \ No newline at end of file