use std::error::Error; use std::ffi::OsStr; use std::fmt::{Display, Formatter}; use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::path::Component::Normal; use futures::TryStreamExt; use log::{debug, info, warn}; use sha1_smol::Sha1; use tokio::{fs, io}; use crate::assets::{Asset, AssetIndex}; use crate::launcher::download::{MultiDownloader, VerifiedDownload}; use crate::util; use crate::util::{FileVerifyError, IntegrityError}; use crate::version::DownloadInfo; const INDEX_PATH: &'static str = "indexes"; const OBJECT_PATH: &'static str = "objects"; pub struct AssetRepository { online: bool, home: PathBuf } #[derive(Debug)] pub enum AssetError { InvalidId(Option), IO { what: &'static str, error: io::Error }, IndexParse(serde_json::Error), Offline, MissingURL, DownloadIndex(reqwest::Error), Integrity(IntegrityError), AssetObjectDownload, AssetVerifyError(FileVerifyError) } impl Display for AssetError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { AssetError::InvalidId(None) => f.write_str("missing asset index id"), AssetError::InvalidId(Some(id)) => write!(f, "invalid asset index id: {}", id), AssetError::IO { what, error } => write!(f, "i/o error ({}): {}", what, error), AssetError::IndexParse(error) => write!(f, "error parsing asset index: {}", error), AssetError::Offline => f.write_str("cannot download asset index while offline"), AssetError::MissingURL => f.write_str("missing asset index URL"), AssetError::DownloadIndex(e) => write!(f, "error downloading asset index: {}", e), AssetError::Integrity(e) => write!(f, "asset index integrity error: {}", e), AssetError::AssetObjectDownload => f.write_str("asset object download failed"), AssetError::AssetVerifyError(e) => write!(f, "error verifying asset object: {e}") } } } impl Error for AssetError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { AssetError::IO { error, .. } => Some(error), AssetError::IndexParse(error) => Some(error), AssetError::DownloadIndex(error) => Some(error), AssetError::Integrity(error) => Some(error), AssetError::AssetVerifyError(error) => Some(error), _ => None } } } impl From<(&'static str, io::Error)> for AssetError { fn from((what, error): (&'static str, io::Error)) -> Self { AssetError::IO { what, error } } } impl AssetRepository { pub async fn new(online: bool, home: impl AsRef) -> Result { let home = home.as_ref().to_owned(); match fs::create_dir_all(&home).await { Ok(_) => (), Err(e) => match e.kind() { ErrorKind::AlreadyExists => (), _ => return Err(e) } }; Ok(AssetRepository { online, home }) } fn get_index_path(&self, id: &str) -> Result { let mut indexes_path: PathBuf = [self.home.as_ref(), OsStr::new(INDEX_PATH)].iter().collect(); let Some(Normal(path)) = Path::new(id).components().last() else { return Err(AssetError::InvalidId(Some(id.into()))); }; let path = path.to_str().ok_or(AssetError::InvalidId(Some(path.to_string_lossy().into())))?; // FIXME: change this once "add_extension" is stabilized indexes_path.push(format!("{}.json", path)); Ok(indexes_path) } pub async fn load_index(&self, index: &DownloadInfo, id: Option<&str>) -> Result { let Some(id) = index.id.as_ref().map(|s| s.as_str()).or(id) else { return Err(AssetError::InvalidId(None)); }; info!("Loading asset index {}", id); let path = self.get_index_path(id)?; debug!("Asset index {} is located at {}", id, path.display()); match util::verify_file(&path, index.size, index.sha1).await { Ok(_) => { debug!("Asset index {} verified on disk. Loading it.", id); let idx_data = fs::read_to_string(&path).await.map_err(|e| AssetError::IO { what: "reading asset index", error: e })?; return Ok(serde_json::from_str(&idx_data).map_err(|e| AssetError::IndexParse(e))?); }, Err(FileVerifyError::Open(_, e)) => match e.kind() { ErrorKind::NotFound => { debug!("Asset index {} not found on disk. Must download it.", id); }, _ => return Err(("opening asset index", e).into()) }, Err(FileVerifyError::Integrity(_, e)) => { info!("Asset index {} has mismatched integrity: {}, must download it.", id, e); let _ = fs::remove_file(&path).await.map_err(|e| warn!("Error deleting modified index {}: {} (ignored)", id, e)); }, Err(FileVerifyError::Read(_, e)) => return Err(("reading asset index", e).into()) } if !self.online { warn!("Must download asset index {}, but the launcher is in offline mode. Please try again in online mode.", id); return Err(AssetError::Offline); } let Some(url) = index.url.as_ref() else { return Err(AssetError::MissingURL); }; debug!("Downloading asset index {} from {}", id, url); if let Some(parent) = path.parent() { fs::create_dir_all(parent).await.map_err(|e| AssetError::IO { what: "creating asset index folder", error: e })?; } let idx_text = reqwest::get(url).await .map_err(|e| AssetError::DownloadIndex(e))? .text().await .map_err(|e| AssetError::DownloadIndex(e))?; if index.size.is_some_and(|s| s != idx_text.len()) { return Err(AssetError::Integrity(IntegrityError::SizeMismatch { expect: index.size.unwrap(), actual: idx_text.len() })); } if let Some(expect) = index.sha1 { let actual = Sha1::from(&idx_text).digest(); return Err(AssetError::Integrity(IntegrityError::Sha1Mismatch { expect, actual })); } debug!("Saving downloaded asset index to {}", path.display()); fs::write(&path, &idx_text).await.map_err(|e| AssetError::IO { what: "writing asset index", error: e })?; Ok(serde_json::from_str(&idx_text).map_err(|e| AssetError::IndexParse(e))?) } fn get_object_url(obj: &Asset) -> String { format!("{}{:02x}/{}", super::constants::URL_RESOURCE_BASE, obj.hash.bytes()[0], obj.hash) } async fn ensure_dir(path: impl AsRef) -> Result<(), io::Error> { match fs::create_dir(path).await { Ok(_) => Ok(()), Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(()), Err(e) => Err(e) } } pub async fn ensure_assets(&self, index: &AssetIndex) -> Result<(), AssetError> { let mut downloads = Vec::new(); let objects_path = [self.home.as_ref(), OsStr::new(OBJECT_PATH)].iter().collect::(); Self::ensure_dir(&objects_path).await.map_err(|e| AssetError::IO { what: "creating objects directory", error: e })?; for object in index.objects.iter() { let hex_digest = object.hash.to_string(); let path = [objects_path.as_ref(), OsStr::new(&hex_digest[..2]), OsStr::new(&hex_digest)].iter().collect::(); Self::ensure_dir(path.parent().unwrap()).await.map_err(|error| AssetError::IO { error, what: "creating directory for object" })?; downloads.push(VerifiedDownload::new(&Self::get_object_url(object), &path, Some(object.size), Some(object.hash))); } if self.online { info!("Downloading {} asset objects...", downloads.len()); let mut multi = MultiDownloader::new(downloads); multi.perform().await .inspect_err(|e| warn!("asset download failed: {e}")) .try_fold((), |_, _| async {Ok(())}) .await .map_err(|_| AssetError::AssetObjectDownload)?; } else { info!("Verifying {} asset objects...", downloads.len()); super::download::verify_files(downloads).await.map_err(|e| AssetError::AssetVerifyError(e))?; } Ok(()) } } mod tests { use super::*; #[test] fn test_it() { let digest_str = "ad1115931887a73cd596300f2c93f84adf39521d"; assert_eq!(AssetRepository::get_object_url(&Asset { name: String::from("test"), hash: digest_str.parse().unwrap(), size: 0usize }), "https://resources.download.minecraft.net/ad/ad1115931887a73cd596300f2c93f84adf39521d"); } }