use std::{collections::{BTreeMap, HashMap}, error::Error, io::ErrorKind}; use std::borrow::Cow; use std::collections::HashSet; use std::fmt::Display; use std::path::{Path, PathBuf}; use log::{debug, info, trace, warn}; use sha1_smol::Digest; use tokio::{fs, io}; use crate::util; use crate::version::{*, manifest::*}; use super::constants::*; struct RemoteVersionList { versions: HashMap, latest: LatestVersions } impl RemoteVersionList { async fn new() -> Result> { debug!("Looking up remote version manifest."); let text = reqwest::get(URL_VERSION_MANIFEST).await?.error_for_status()?.text().await?; debug!("Parsing version manifest."); let manifest: VersionManifest = serde_json::from_str(text.as_str())?; let mut versions = HashMap::new(); for v in manifest.versions { versions.insert(v.id.clone(), v); } debug!("Done loading remote versions!"); Ok(RemoteVersionList { versions, latest: manifest.latest }) } async fn download_version(&self, ver: &VersionManifestVersion, path: &Path) -> Result> { // ensure parent directory exists info!("Downloading version {}.", ver.id); match tokio::fs::create_dir_all(path.parent().expect("version .json has no parent (impossible)")).await { Err(e) => { if e.kind() != ErrorKind::AlreadyExists { warn!("failed to create {} parent dirs: {e}", path.to_string_lossy()); return Err(e.into()); } }, Ok(()) => {} } // download it let ver_text = reqwest::get(ver.url.as_str()).await?.error_for_status()?.text().await?; debug!("Validating downloaded {}...", ver.id); // make sure it's valid util::verify_sha1(ver.sha1, ver_text.as_str()) .map_err::, _>(|e| format!("downloaded version {} has wrong hash! (expect {}, got {})", ver.id.as_str(), &ver.sha1, e).as_str().into())?; // make sure it's well-formed let cver: CompleteVersion = serde_json::from_str(ver_text.as_str())?; debug!("Saving version {}...", ver.id); // write it out tokio::fs::write(path, ver_text).await.map_err(|e| { warn!("Failed to save version {}: {}", ver.id, e); e })?; info!("Done downloading and verifying {}!", ver.id); Ok(cver) } } struct LocalVersionList { versions: BTreeMap } #[derive(Debug)] enum LocalVersionError { Sha1Mismatch { exp: Digest, got: Digest }, VersionMismatch { fname: String, json: String }, Unknown(Box) } impl Display for LocalVersionError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { LocalVersionError::Sha1Mismatch { exp, got } => { write!(f, "sha1 mismatch (exp {exp}, got {got})") }, LocalVersionError::VersionMismatch { fname, json } => { write!(f, "version ID mismatch (filename {fname}, json {json})") }, LocalVersionError::Unknown(err) => { write!(f, "unknown version error: {err}") } } } } impl Error for LocalVersionError {} impl LocalVersionList { async fn load_version(path: &Path, sha1: Option) -> Result { // grumble grumble I don't like reading in the whole file at once info!("Loading local version at {}.", path.display()); let ver = tokio::fs::read_to_string(path).await.map_err(|e| LocalVersionError::Unknown(Box::new(e)))?; if let Some(digest_exp) = sha1 { debug!("Verifying local version {}.", path.display()); util::verify_sha1(digest_exp, ver.as_str()) .map_err(|got| { warn!("Local version sha1 mismatch: {} (exp: {}, got: {})", path.display(), digest_exp, got); LocalVersionError::Sha1Mismatch { exp: digest_exp.to_owned(), got } })?; } let ver: CompleteVersion = serde_json::from_str(ver.as_str()).map_err(|e| { warn!("Invalid version JSON {}: {}", path.display(), e); LocalVersionError::Unknown(Box::new(e)) })?; let fname_id = path.file_stem() .expect("tried to load a local version with no path") // should be impossible .to_str() .expect("tried to load a local version with invalid UTF-8 filename"); // we already checked if the filename is valid UTF-8 at this point if fname_id == ver.id.as_str() { info!("Loaded local version {}.", ver.id); Ok(ver) } else { warn!("Local version {} has a version ID conflict (filename: {}, json: {})!", path.display(), fname_id, ver.id); Err(LocalVersionError::VersionMismatch { fname: fname_id.to_owned(), json: ver.id }) } } async fn load_versions(home: &Path, skip: impl Fn(&str) -> bool) -> Result> { info!("Loading local versions."); let mut rd = tokio::fs::read_dir(home).await?; let mut versions = BTreeMap::new(); while let Some(ent) = rd.next_entry().await? { if !ent.file_type().await?.is_dir() { continue; } // when the code is fugly let path = match ent.file_name().to_str() { Some(s) => { if skip(s) { debug!("Skipping local version {s} because (I assume) it is remotely tracked."); continue } /* FIXME: once https://github.com/rust-lang/rust/issues/127292 is closed, * use add_extension to avoid extra heap allocations (they hurt my feelings) */ let mut path = ent.path(); // can't use set_extension since s might contain a . (like 1.8.9) path.push(format!("{s}.json")); path }, /* We just ignore directories with names that contain invalid unicode. Unfortunately, the laucher * will not be supporting such custom versions. Name your version something sensible please. */ None => { warn!("Ignoring a local version {} because its id contains invalid unicode.", ent.file_name().to_string_lossy()); continue } }; match Self::load_version(&path, None).await { Ok(v) => { versions.insert(v.id.clone(), v); }, Err(e) => { // FIXME: just display the filename without to_string_lossy when https://github.com/rust-lang/rust/issues/120048 is closed warn!("Ignoring local version {}: {e}", ent.file_name().to_string_lossy()); } } } info!("Loaded {} local version(s).", versions.len()); Ok(LocalVersionList { versions }) } } pub struct VersionList { remote: Option, local: LocalVersionList, home: PathBuf } pub enum VersionResult<'a> { Complete(&'a CompleteVersion), Remote(&'a VersionManifestVersion), None } impl<'a> From<&'a CompleteVersion> for VersionResult<'a> { fn from(value: &'a CompleteVersion) -> Self { Self::Complete(value) } } impl<'a> From<&'a VersionManifestVersion> for VersionResult<'a> { fn from(value: &'a VersionManifestVersion) -> Self { Self::Remote(value) } } impl<'a, T: Into>> From> for VersionResult<'a> { fn from(value: Option) -> Self { value.map_or(VersionResult::None, |v| v.into()) } } #[derive(Debug)] pub enum VersionResolveError { InheritanceLoop(String), MissingVersion(String), Unknown(Box) } impl Display for VersionResolveError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { VersionResolveError::InheritanceLoop(s) => write!(f, "inheritance loop (saw {s} twice)"), VersionResolveError::MissingVersion(s) => write!(f, "unknown version {s}"), VersionResolveError::Unknown(err) => write!(f, "unknown error: {err}") } } } impl Error for VersionResolveError {} impl VersionList { async fn create_dir_for(home: &Path) -> Result<(), io::Error> { debug!("Creating versions directory."); match fs::create_dir(home).await { Ok(_) => Ok(()), Err(e) => match e.kind() { ErrorKind::AlreadyExists => Ok(()), _ => { debug!("failed to create version home: {}", e); Err(e) } } } } pub async fn online(home: &Path) -> Result> { Self::create_dir_for(home).await?; let remote = RemoteVersionList::new().await?; let local = LocalVersionList::load_versions(home.as_ref(), |s| remote.versions.contains_key(s)).await?; Ok(VersionList { remote: Some(remote), local, home: home.to_path_buf() }) } pub async fn offline(home: &Path) -> Result> { Self::create_dir_for(home).await?; let local = LocalVersionList::load_versions(home, |_| false).await?; Ok(VersionList { remote: None, local, home: home.to_path_buf() }) } pub fn is_online(&self) -> bool { self.remote.is_some() } pub fn get_version_lazy(&self, id: &str) -> VersionResult { self.remote.as_ref() .map_or_else(|| self.local.versions.get(id).into(), |r| r.versions.get(id).into()) } pub fn get_remote_version(&self, id: &str) -> Option<&VersionManifestVersion> { let remote = self.remote.as_ref().expect("get_remote_version called in offline mode!"); remote.versions.get(id) } pub async fn load_remote_version(&self, ver: &VersionManifestVersion) -> Result> { let remote = self.remote.as_ref().expect("load_remote_version called in offline mode!"); let id = ver.id.as_str(); let mut ver_path = self.home.join(id); ver_path.push(format!("{id}.json")); debug!("Loading local copy of remote version {}", ver.id); match LocalVersionList::load_version(ver_path.as_path(), Some(ver.sha1)).await { Ok(v) => return Ok(v), Err(e) => { info!("Redownloading {id}, since the local copy could not be loaded: {e}"); } } remote.download_version(ver, ver_path.as_path()).await } pub async fn resolve_version<'v>(&self, ver: &'v CompleteVersion) -> Result, VersionResolveError> { let mut seen: HashSet = HashSet::new(); seen.insert(ver.id.clone()); let Some(inherit) = ver.inherits_from.as_ref() else { return Ok(Cow::Borrowed(ver)); }; if *inherit == ver.id { warn!("Version {} directly inherits from itself!", ver.id); return Err(VersionResolveError::InheritanceLoop(ver.id.clone())); } debug!("Resolving version inheritance: {} (inherits from {})", ver.id, inherit); let mut ver = ver.clone(); let mut inherit = inherit.clone(); loop { if seen.insert(inherit.clone()) { warn!("Version inheritance loop detected in {}: {} transitively inherits from itself.", ver.id, inherit); return Err(VersionResolveError::InheritanceLoop(inherit)); } let inherited_ver = match self.get_version_lazy(inherit.as_str()) { VersionResult::Complete(v) => Cow::Borrowed(v), VersionResult::Remote(v) => Cow::Owned(self.load_remote_version(v).await.map_err(|e| VersionResolveError::Unknown(e))?), VersionResult::None => { warn!("Cannot resolve version {}, it inherits an unknown version {inherit}", ver.id); return Err(VersionResolveError::MissingVersion(inherit)); } }; ver.apply_child(inherited_ver.as_ref()); let Some(new_inherit) = inherited_ver.inherits_from.as_ref() else { break }; inherit.replace_range(.., new_inherit.as_str()); } Ok(Cow::Owned(ver)) } }