use crate::instance::QuickPlayType; use crate::launcher::{download, parse_rules}; use crc32fast::Hasher; use daedalus::minecraft::Library; use fs4::tokio::AsyncFileExt; use std::collections::{BTreeMap, HashSet}; use std::io::{Read, Write}; use std::path::{Component, Path, PathBuf}; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct NativeArchive { pub library_name: String, pub classifier: String, pub archive_path: PathBuf, pub sha1: Option, pub exclude: Vec, } #[derive(Clone, Debug)] struct NativeEntry { relative_path: PathBuf, uncompressed_size: u64, crc32: u32, archive_path: PathBuf, archive_entry_index: usize, } #[derive(Clone, Debug, Default, PartialEq, Eq)] pub(crate) struct NativePreparationReport { pub verified: usize, pub restored: Vec, } pub(crate) fn resolve_native_archives( libraries_dir: &Path, caches_dir: &Path, libraries: &[Library], java_arch: &str, minecraft_updated: bool, ) -> crate::Result> { let mut archives = Vec::new(); let mut identities = HashSet::new(); for library in libraries { if let Some(rules) = &library.rules && !parse_rules( rules, java_arch, &QuickPlayType::None, minecraft_updated, ) { continue; } if !library.downloadable || !download::is_native_library(library) { continue; } let Some(classifier) = download::library_native_classifier(library, java_arch) else { continue; }; let native = library .downloads .as_ref() .and_then(|downloads| downloads.classifiers.as_ref()) .and_then(|classifiers| classifiers.get(&classifier)); let classified_path = libraries_dir.join( if let Some(path) = native.and_then(|native| native.path.as_deref()) { path.to_owned() } else { download::native_library_artifact_path(library, &classifier)? }, ); let (archive_path, sha1) = if let Some(native) = native { let cached = caches_dir .join("minecraft-natives") .join(format!("{}.jar", native.sha1)); // Modern native archives are content-addressed by SHA1. Do not // silently substitute the Maven artifact: it can be a different // classifier/version and produce an ABI-incompatible DLL set. if cached.is_file() { (cached, Some(native.sha1.clone())) } else if classified_path.is_file() && (native.sha1.len() != 40 || sha1_file(&classified_path)? == native.sha1) { // Older instances downloaded classifiers directly into // libraries/. Reuse that artifact when it is verifiably the // requested archive (or when the legacy metadata has no // usable SHA-1 to verify), allowing repair/import to recover // without a redundant network download. (classified_path, None) } else { (cached, Some(native.sha1.clone())) } } else { (classified_path, None) }; let identity = sha1.clone().unwrap_or_else(|| { archive_path .canonicalize() .unwrap_or_else(|_| archive_path.clone()) .to_string_lossy() .into_owned() }); if !identities.insert(identity) { tracing::debug!( library = %library.name, classifier, "Skipped duplicate native archive" ); continue; } archives.push(NativeArchive { library_name: library.name.clone(), classifier, archive_path, sha1, exclude: library .extract .as_ref() .and_then(|extract| extract.exclude.clone()) .unwrap_or_default(), }); } Ok(archives) } pub(crate) async fn prepare_native_libraries( natives_root: &Path, libraries_dir: &Path, caches_dir: &Path, libraries: &[Library], version: &str, java_arch: &str, minecraft_updated: bool, ) -> crate::Result { let archives = resolve_native_archives( libraries_dir, caches_dir, libraries, java_arch, minecraft_updated, )?; materialize_native_directory(&archives, natives_root, version).await } pub(crate) async fn materialize_native_directory( archives: &[NativeArchive], natives_root: &Path, version: &str, ) -> crate::Result { let natives_dir = natives_root.join(version); let lock_dir = natives_root.join(".locks"); tokio::fs::create_dir_all(&natives_dir).await?; tokio::fs::create_dir_all(&lock_dir).await?; let lock_path = lock_dir.join(format!("{}.lock", safe_version_id(version))); let archives = archives.to_vec(); let version = version.to_string(); let lock = tokio::fs::File::options() .create(true) .write(true) .truncate(false) .open(&lock_path) .await?; let report = tokio::task::spawn_blocking(move || { lock.lock_exclusive().map_err(|error| { crate::ErrorKind::LauncherError(format!( "Failed to lock native directory for Minecraft {version} at {}: {error}", lock_path.display() )) })?; materialize_locked(&archives, &natives_dir, &version) }) .await??; Ok(report) } fn materialize_locked( archives: &[NativeArchive], natives_dir: &Path, version: &str, ) -> crate::Result { let manifest = build_manifest(archives)?; let mut report = NativePreparationReport::default(); for entry in manifest.values() { let target = natives_dir.join(&entry.relative_path); if file_matches(&target, entry.uncompressed_size, entry.crc32)? { report.verified += 1; continue; } materialize_entry(entry, &target, version)?; if !file_matches(&target, entry.uncompressed_size, entry.crc32)? { return Err(native_error(format!( "Native entry {} failed final validation after extraction from {}", target.display(), entry.archive_path.display() ))); } report.restored.push(entry.relative_path.clone()); } tracing::debug!( target = %natives_dir.display(), archives = archives.len(), entries = manifest.len(), verified = report.verified, restored = report.restored.len(), "Prepared Minecraft native directory" ); Ok(report) } fn build_manifest( archives: &[NativeArchive], ) -> crate::Result> { let mut manifest = BTreeMap::new(); for archive in archives { if !archive.archive_path.is_file() { return Err(native_error(format!( "Native archive for {} ({}) is missing at {}. Repair the instance while online and try again", archive.library_name, archive.classifier, archive.archive_path.display() ))); } if let Some(expected_sha1) = archive.sha1.as_deref() && expected_sha1.len() == 40 { let actual_sha1 = sha1_file(&archive.archive_path)?; if actual_sha1 != expected_sha1 { return Err(native_error(format!( "Native archive for {} ({}) at {} has SHA1 {}, expected {}. Repair the instance while online and try again", archive.library_name, archive.classifier, archive.archive_path.display(), actual_sha1, expected_sha1 ))); } } let file = std::fs::File::open(&archive.archive_path)?; let mut zip = zip::ZipArchive::new(file).map_err(|error| { native_error(format!( "Failed to open native archive for {} ({}) at {}: {error}", archive.library_name, archive.classifier, archive.archive_path.display() )) })?; for index in 0..zip.len() { let entry = zip.by_index(index).map_err(|error| { native_error(format!( "Failed to inspect native archive {}: {error}", archive.archive_path.display() )) })?; if entry.is_dir() { continue; } let Some(relative_path) = safe_entry_path(entry.name()) else { tracing::warn!( archive = %archive.archive_path.display(), entry = entry.name(), "Ignored unsafe native archive entry" ); continue; }; let normalized = relative_path.to_string_lossy().replace('\\', "/"); if normalized == "META-INF" || normalized.starts_with("META-INF/") { continue; } if archive.exclude.iter().any(|excluded| { let excluded = excluded.replace('\\', "/"); normalized == excluded.trim_end_matches('/') || normalized.starts_with(&format!( "{}/", excluded.trim_end_matches('/') )) }) { continue; } if let Some(previous) = manifest.insert( relative_path.clone(), NativeEntry { relative_path, uncompressed_size: entry.size(), crc32: entry.crc32(), archive_path: archive.archive_path.clone(), archive_entry_index: index, }, ) && (previous.uncompressed_size != entry.size() || previous.crc32 != entry.crc32()) { tracing::debug!( entry = normalized, previous = %previous.archive_path.display(), selected = %archive.archive_path.display(), "Native entry is provided by multiple archives; using metadata order" ); } } } Ok(manifest) } fn safe_entry_path(name: &str) -> Option { if name.is_empty() || name.contains('\\') || name.starts_with('/') || name.as_bytes().get(1) == Some(&b':') { return None; } let path = Path::new(name); if path.is_absolute() || path.components().any(|component| { matches!( component, Component::ParentDir | Component::RootDir | Component::Prefix(_) ) }) { return None; } Some(path.to_path_buf()) } fn file_matches(path: &Path, size: u64, crc32: u32) -> crate::Result { let Ok(metadata) = std::fs::symlink_metadata(path) else { return Ok(false); }; if !metadata.is_file() || metadata.len() != size { return Ok(false); } let mut file = std::fs::File::open(path)?; let mut hasher = Hasher::new(); let mut buffer = [0_u8; 64 * 1024]; loop { let read = file.read(&mut buffer)?; if read == 0 { break; } hasher.update(&buffer[..read]); } Ok(hasher.finalize() == crc32) } fn sha1_file(path: &Path) -> crate::Result { let mut file = std::fs::File::open(path)?; let mut hasher = sha1_smol::Sha1::new(); let mut buffer = [0_u8; 64 * 1024]; loop { let read = file.read(&mut buffer)?; if read == 0 { break; } hasher.update(&buffer[..read]); } Ok(hasher.digest().to_string()) } fn materialize_entry( entry: &NativeEntry, target: &Path, version: &str, ) -> crate::Result<()> { let parent = target.parent().ok_or_else(|| { native_error(format!( "Native target {} has no parent", target.display() )) })?; std::fs::create_dir_all(parent)?; let file = std::fs::File::open(&entry.archive_path)?; let mut archive = zip::ZipArchive::new(file).map_err(|error| { native_error(format!( "Failed to open native archive {}: {error}", entry.archive_path.display() )) })?; let mut source = archive .by_index(entry.archive_entry_index) .map_err(|error| { native_error(format!( "Failed to read native entry {} from {}: {error}", entry.relative_path.display(), entry.archive_path.display() )) })?; let mut temporary = tempfile::Builder::new() .prefix(&format!(".tmp-native-{}-", safe_version_id(version))) .tempfile_in(parent)?; let mut hasher = Hasher::new(); let mut written = 0_u64; let mut buffer = [0_u8; 64 * 1024]; loop { let read = source.read(&mut buffer)?; if read == 0 { break; } temporary.write_all(&buffer[..read])?; hasher.update(&buffer[..read]); written += read as u64; } if written != entry.uncompressed_size || hasher.finalize() != entry.crc32 { return Err(native_error(format!( "Native entry {} extracted from {} did not match its ZIP size and CRC32", entry.relative_path.display(), entry.archive_path.display() ))); } temporary.flush()?; temporary.as_file().sync_all()?; if let Ok(metadata) = std::fs::symlink_metadata(target) { if metadata.file_type().is_dir() { std::fs::remove_dir_all(target)?; } else if !metadata.file_type().is_file() { std::fs::remove_file(target)?; } } temporary.persist(target).map_err(|error| { native_error(format!( "Failed to atomically replace native entry {}: {}", target.display(), error.error )) })?; Ok(()) } fn safe_version_id(version: &str) -> String { let sanitized: String = version .chars() .map(|character| { if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { character } else { '_' } }) .collect(); if sanitized.is_empty() { "unknown".to_string() } else { sanitized } } fn native_error(message: String) -> crate::Error { crate::ErrorKind::LauncherError(message).into() } #[cfg(test)] mod tests { use super::*; fn write_archive(path: &Path, entries: &[(&str, &[u8])]) { let file = std::fs::File::create(path).unwrap(); let mut writer = zip::ZipWriter::new(file); let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Stored); for (name, contents) in entries { writer.start_file(*name, options).unwrap(); writer.write_all(contents).unwrap(); } writer.finish().unwrap(); } fn archive(path: PathBuf) -> NativeArchive { NativeArchive { library_name: "org.lwjgl:lwjgl:3.2.2".to_string(), classifier: "natives-windows".to_string(), archive_path: path, sha1: None, exclude: Vec::new(), } } #[tokio::test] async fn concurrent_launch_native_directories_do_not_replace_each_other() { let archives = tempfile::tempdir().unwrap(); let first = tempfile::tempdir().unwrap(); let second = tempfile::tempdir().unwrap(); let source = archives.path().join("natives.jar"); write_archive(&source, &[("lwjgl.dll", b"first runtime")]); materialize_native_directory( &[archive(source.clone())], first.path(), "natives", ) .await .unwrap(); let first_dll = first.path().join("natives/lwjgl.dll"); #[cfg(windows)] let _running_dll = { use std::os::windows::fs::OpenOptionsExt; std::fs::OpenOptions::new() .read(true) .share_mode(1) .open(&first_dll) .unwrap() }; write_archive(&source, &[("lwjgl.dll", b"second runtime")]); materialize_native_directory( &[archive(source)], second.path(), "natives", ) .await .unwrap(); assert_eq!(std::fs::read(&first_dll).unwrap(), b"first runtime"); assert_eq!( std::fs::read(second.path().join("natives/lwjgl.dll")).unwrap(), b"second runtime" ); drop(second); assert!(first_dll.exists()); } #[tokio::test] async fn repairs_missing_truncated_same_size_and_directory_entries() { let root = tempfile::tempdir().unwrap(); let archive_path = root.path().join("natives.jar"); write_archive( &archive_path, &[ ("missing.dll", b"missing"), ("bad.dll", b"correct"), ("folder.dll", b"file"), ], ); let target = root.path().join("target"); std::fs::create_dir_all(target.join("version/folder.dll")).unwrap(); std::fs::write(target.join("version/bad.dll"), b"xxxxxxx").unwrap(); let report = materialize_native_directory( &[archive(archive_path)], &target, "version", ) .await .unwrap(); assert_eq!(report.restored.len(), 3); assert_eq!( std::fs::read(target.join("version/missing.dll")).unwrap(), b"missing" ); assert_eq!( std::fs::read(target.join("version/bad.dll")).unwrap(), b"correct" ); assert_eq!( std::fs::read(target.join("version/folder.dll")).unwrap(), b"file" ); } #[tokio::test] async fn preparation_is_idempotent_and_leaves_no_temporary_files() { let root = tempfile::tempdir().unwrap(); let archive_path = root.path().join("natives.jar"); write_archive(&archive_path, &[("lwjgl.dll", b"native")]); let archives = [archive(archive_path)]; let first = materialize_native_directory(&archives, root.path(), "1.18.2") .await .unwrap(); let second = materialize_native_directory(&archives, root.path(), "1.18.2") .await .unwrap(); assert_eq!(first.restored, [PathBuf::from("lwjgl.dll")]); assert_eq!(second.verified, 1); assert!(second.restored.is_empty()); assert!( std::fs::read_dir(root.path().join("1.18.2")) .unwrap() .flatten() .all(|entry| !entry .file_name() .to_string_lossy() .starts_with(".tmp-native-")) ); } #[test] fn modern_native_resolution_does_not_fallback_to_unverified_maven_artifact() { let root = tempfile::tempdir().unwrap(); let libraries_dir = root.path().join("libraries"); let caches_dir = root.path().join("caches"); let classified = libraries_dir .join("org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar"); std::fs::create_dir_all(classified.parent().unwrap()).unwrap(); write_archive(&classified, &[("lwjgl.dll", b"wrong")]); let library: Library = serde_json::from_value(serde_json::json!({ "name": "org.lwjgl:lwjgl:3.2.2", "natives": {"windows": "natives-windows"}, "downloads": {"classifiers": {"natives-windows": { "sha1": "05359f3aa50d36352815fc662ea73e1c00d22170", "size": 279593, "url": "https://libraries.minecraft.net/native.jar" }}} })) .unwrap(); let archives = resolve_native_archives( &libraries_dir, &caches_dir, &[library], "x86_64", true, ) .unwrap(); assert_eq!(archives.len(), 1); assert_eq!( archives[0].archive_path, caches_dir.join( "minecraft-natives/05359f3aa50d36352815fc662ea73e1c00d22170.jar" ) ); } #[test] fn classifier_native_library_is_selected_for_preparation() { let root = tempfile::tempdir().unwrap(); let library: Library = serde_json::from_value(serde_json::json!({ "name": "org.lwjgl:lwjgl:3.2.2:natives-windows", "downloads": { "artifact": { "sha1": "abc", "size": 1, "url": "https://example.com/native.jar" } } })) .unwrap(); let archives = resolve_native_archives( &root.path().join("libraries"), &root.path().join("caches"), &[library], "x86_64", true, ) .unwrap(); assert_eq!(archives.len(), 1); assert_eq!(archives[0].classifier, "natives-windows"); assert_eq!( archives[0].archive_path, root.path().join( "libraries/org/lwjgl/lwjgl/3.2.2/lwjgl-3.2.2-natives-windows.jar" ) ); } #[tokio::test] async fn rejects_modern_archives_with_the_wrong_sha1() { let root = tempfile::tempdir().unwrap(); let archive_path = root.path().join("natives.jar"); write_archive(&archive_path, &[("lwjgl.dll", b"native")]); let mut archive = archive(archive_path); archive.sha1 = Some("0000000000000000000000000000000000000000".to_string()); let error = materialize_native_directory( &[archive], root.path(), "1.18.2-forge-40.2.34", ) .await .unwrap_err(); assert!(error.to_string().contains("has SHA1")); assert!(!root.path().join("1.18.2-forge-40.2.34/lwjgl.dll").exists()); } #[tokio::test] async fn concurrent_preparation_is_serialized() { let root = tempfile::tempdir().unwrap(); let archive_path = root.path().join("natives.jar"); write_archive(&archive_path, &[("lwjgl.dll", b"native")]); let archives = vec![archive(archive_path)]; let root_path = root.path().to_path_buf(); let (left, right) = tokio::join!( materialize_native_directory(&archives, &root_path, "1.18.2"), materialize_native_directory(&archives, &root_path, "1.18.2"), ); let reports = [left.unwrap(), right.unwrap()]; assert_eq!( reports .iter() .map(|report| report.restored.len()) .sum::(), 1 ); assert_eq!( reports.iter().map(|report| report.verified).sum::(), 1 ); } #[tokio::test] async fn unsafe_meta_inf_and_duplicate_entries_are_deterministic() { let root = tempfile::tempdir().unwrap(); let first = root.path().join("first.jar"); let second = root.path().join("second.jar"); write_archive( &first, &[ ("same.dll", b"first"), ("META-INF/MANIFEST.MF", b"meta"), ("excluded/skip.dll", b"skip"), ], ); write_archive( &second, &[ ("../evil.dll", b"evil"), ("C:/evil.dll", b"evil"), ("same.dll", b"later"), ], ); let mut first = archive(first); first.exclude = vec!["excluded/".to_string()]; materialize_native_directory( &[first, archive(second)], root.path(), "version", ) .await .unwrap(); assert_eq!( std::fs::read(root.path().join("version/same.dll")).unwrap(), b"later" ); assert!(!root.path().join("evil.dll").exists()); assert!(!root.path().join("version/META-INF").exists()); assert!(!root.path().join("version/excluded").exists()); } }