diff --git a/crates/empack-lib/src/application/commands.rs b/crates/empack-lib/src/application/commands.rs index ded3257..d305db2 100644 --- a/crates/empack-lib/src/application/commands.rs +++ b/crates/empack-lib/src/application/commands.rs @@ -3327,7 +3327,7 @@ async fn handle_build(session: &dyn Session, args: &BuildArgs) -> Result<()> { let restricted_entries = collect_restricted_entries(&results); if !restricted_entries.is_empty() { - let pending = crate::empack::restricted_build::save_pending_build( + let mut pending = crate::empack::restricted_build::save_pending_build( session.filesystem(), &manager.workdir, &build_targets, @@ -3338,6 +3338,17 @@ async fn handle_build(session: &dyn Session, args: &BuildArgs) -> Result<()> { let download_dirs = restricted_download_dirs(args.downloads_dir.as_deref(), &pending, &pending.entries); + pending.candidate_baseline = crate::empack::restricted_build::capture_candidate_baseline( + session.filesystem(), + &download_dirs, + ) + .context("Failed to capture restricted download baseline")?; + crate::empack::restricted_build::persist_pending_build( + session.filesystem(), + &manager.workdir, + &pending, + ) + .context("Failed to persist restricted download baseline")?; crate::empack::restricted_build::import_matching_downloads_into_cache( session.filesystem(), &manager.workdir, @@ -3856,17 +3867,35 @@ async fn maybe_open_and_wait_for_restricted_downloads( .status() .info("Waiting up to 5 minutes for restricted downloads to appear..."); + let mut pending_for_polling = pending.clone(); + if pending_for_polling.candidate_baseline.is_empty() { + pending_for_polling.candidate_baseline = + crate::empack::restricted_build::capture_candidate_baseline( + session.filesystem(), + search_dirs, + ) + .context("Failed to capture restricted download baseline")?; + crate::empack::restricted_build::persist_pending_build( + session.filesystem(), + workdir, + &pending_for_polling, + ) + .context("Failed to persist restricted download baseline")?; + } + for _ in 0..300 { crate::empack::restricted_build::import_matching_downloads_into_cache( session.filesystem(), workdir, - pending, + &pending_for_polling, search_dirs, ) .context("Failed to import matching restricted downloads into cache")?; - let remaining = - crate::empack::restricted_build::missing_cached_entries(session.filesystem(), pending); + let remaining = crate::empack::restricted_build::missing_cached_entries( + session.filesystem(), + &pending_for_polling, + ); if remaining.is_empty() { return Ok(true); } diff --git a/crates/empack-lib/src/application/commands.test.rs b/crates/empack-lib/src/application/commands.test.rs index 2ba70a3..48c9f86 100644 --- a/crates/empack-lib/src/application/commands.test.rs +++ b/crates/empack-lib/src/application/commands.test.rs @@ -4880,6 +4880,173 @@ mod handle_build_continue_tests { ); } + #[tokio::test] + async fn build_continue_detects_exact_deceasedcraft_section_sign_filename() { + let _guard = crate::test_support::env_lock().lock_async().await; + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("continue-import-exact-deceasedcraft-filename"); + let downloads_dir = workdir.join("manual-downloads"); + let exact_name = "§6No Enchant Glint 1.20.1.zip"; + let download_bytes = b"manual deceasedcraft bytes".to_vec(); + let filesystem = + cached_full_build_filesystem(workdir.clone()).with_binary_file_and_metadata( + downloads_dir.join(exact_name), + download_bytes.clone(), + recent_file_metadata(download_bytes.len()), + ); + let session = MockCommandSession::new() + .with_filesystem(filesystem) + .with_process(MockProcessProvider::new().with_mrpack_export_side_effects()); + + let pending = crate::empack::restricted_build::save_pending_build( + session.filesystem(), + &workdir, + &[BuildTarget::Mrpack], + crate::empack::archive::ArchiveFormat::Zip, + &[crate::empack::RestrictedModInfo { + name: "No Enchant Glint".to_string(), + url: "https://www.curseforge.com/minecraft/texture-packs/no-enchant-glint/download/4660358" + .to_string(), + dest_path: workdir + .join("packwiz-cache") + .join("import") + .join(exact_name) + .to_string_lossy() + .to_string(), + }], + ) + .expect("save pending build"); + + let result = handle_build( + &session, + &BuildArgs { + continue_build: true, + downloads_dir: Some(downloads_dir.to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await; + + assert!( + result.is_ok(), + "exact deceasedcraft filename should be detected and continued: {result:?}" + ); + assert!( + session + .filesystem() + .exists(&pending.restricted_cache_path().join(exact_name)), + "exact filename should be imported into the managed restricted cache" + ); + assert!( + session + .filesystem() + .exists(&PathBuf::from(&pending.entries[0].dest_path)), + "exact cached file should be restored into the packwiz import destination" + ); + } + + #[tokio::test] + async fn build_continue_ignores_preexisting_recent_zip_noise_when_baseline_exists() { + let _guard = crate::test_support::env_lock().lock_async().await; + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("continue-import-baseline-noise"); + let downloads_dir = workdir.join("manual-downloads"); + let noise_a = downloads_dir.join("noise-a.zip"); + let noise_b = downloads_dir.join("noise-b.zip"); + let noise_c = downloads_dir.join("noise-c.zip"); + let exact_variant = downloads_dir.join("§6No Enchant Glint 1.20.1.zip"); + let noise_a_meta = recent_file_metadata("noise-a".len()); + let noise_b_meta = recent_file_metadata("noise-b".len()); + let noise_c_meta = recent_file_metadata("noise-c".len()); + let filesystem = cached_full_build_filesystem(workdir.clone()) + .with_binary_file_and_metadata(noise_a.clone(), b"noise-a".to_vec(), noise_a_meta.clone()) + .with_binary_file_and_metadata(noise_b.clone(), b"noise-b".to_vec(), noise_b_meta.clone()) + .with_binary_file_and_metadata(noise_c.clone(), b"noise-c".to_vec(), noise_c_meta.clone()); + let session = MockCommandSession::new() + .with_filesystem(filesystem) + .with_process(MockProcessProvider::new().with_mrpack_export_side_effects()); + + let mut pending = crate::empack::restricted_build::save_pending_build( + session.filesystem(), + &workdir, + &[BuildTarget::Mrpack], + crate::empack::archive::ArchiveFormat::Zip, + &[crate::empack::RestrictedModInfo { + name: "No Enchant Glint".to_string(), + url: "https://www.curseforge.com/minecraft/texture-packs/no-enchant-glint/download/4660358" + .to_string(), + dest_path: workdir + .join("packwiz-cache") + .join("import") + .join("No_Enchant_Glint.zip") + .to_string_lossy() + .to_string(), + }], + ) + .expect("save pending build"); + pending.candidate_baseline = vec![ + crate::empack::restricted_build::PendingRestrictedCandidateSnapshot { + path: noise_a.to_string_lossy().to_string(), + len: noise_a_meta.len, + modified_unix_ms: noise_a_meta.modified_unix_ms, + created_unix_ms: noise_a_meta.created_unix_ms, + }, + crate::empack::restricted_build::PendingRestrictedCandidateSnapshot { + path: noise_b.to_string_lossy().to_string(), + len: noise_b_meta.len, + modified_unix_ms: noise_b_meta.modified_unix_ms, + created_unix_ms: noise_b_meta.created_unix_ms, + }, + crate::empack::restricted_build::PendingRestrictedCandidateSnapshot { + path: noise_c.to_string_lossy().to_string(), + len: noise_c_meta.len, + modified_unix_ms: noise_c_meta.modified_unix_ms, + created_unix_ms: noise_c_meta.created_unix_ms, + }, + ]; + crate::empack::restricted_build::persist_pending_build( + session.filesystem(), + &workdir, + &pending, + ) + .expect("persist baseline-aware pending build"); + + session + .filesystem() + .write_bytes(&exact_variant, b"manual bytes") + .expect("write exact variant"); + session + .filesystem_provider + .set_file_metadata(exact_variant, recent_file_metadata("manual bytes".len())); + + let result = handle_build( + &session, + &BuildArgs { + continue_build: true, + downloads_dir: Some(downloads_dir.to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await; + + assert!( + result.is_ok(), + "baseline-aware continue should ignore preexisting zip noise: {result:?}" + ); + let restricted_cache_dir = + crate::empack::restricted_build::restricted_cache_dir(&workdir).expect("cache dir"); + assert!( + session + .filesystem() + .exists(&restricted_cache_dir.join("No_Enchant_Glint.zip")), + "the new variant should still be imported into the expected cache filename" + ); + } + #[tokio::test] async fn fresh_restricted_build_non_tty_does_not_prompt_or_wait() { let _guard = crate::test_support::env_lock().lock_async().await; @@ -5183,6 +5350,99 @@ mod handle_build_continue_tests { assert_eq!(browser_calls[0].args, expected_args); } + #[tokio::test] + async fn interactive_wait_loop_captures_baseline_for_legacy_pending_before_polling() { + let _guard = crate::test_support::env_lock().lock_async().await; + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("continue-browser-legacy-baseline"); + let import_dir = workdir.join("packwiz-cache").join("import"); + let noise_a = import_dir.join("noise-a.zip"); + let noise_b = import_dir.join("noise-b.zip"); + let noise_c = import_dir.join("noise-c.zip"); + let exact_variant = import_dir.join("§6No Enchant Glint 1.20.1.zip"); + let filesystem = cached_full_build_filesystem(workdir.clone()) + .with_binary_file_and_metadata( + noise_a.clone(), + b"noise-a".to_vec(), + recent_file_metadata("noise-a".len()), + ) + .with_binary_file_and_metadata( + noise_b.clone(), + b"noise-b".to_vec(), + recent_file_metadata("noise-b".len()), + ) + .with_binary_file_and_metadata( + noise_c.clone(), + b"noise-c".to_vec(), + recent_file_metadata("noise-c".len()), + ); + let session = MockCommandSession::new() + .with_filesystem(filesystem) + .with_process(MockProcessProvider::new().with_mrpack_export_side_effects()) + .with_interactive(MockInteractiveProvider::new().with_confirm(true)) + .with_terminal_capabilities(tty_capabilities()); + + let pending = crate::empack::restricted_build::save_pending_build( + session.filesystem(), + &workdir, + &[BuildTarget::Mrpack], + crate::empack::archive::ArchiveFormat::Zip, + &[crate::empack::RestrictedModInfo { + name: "No Enchant Glint".to_string(), + url: "https://www.curseforge.com/minecraft/texture-packs/no-enchant-glint/download/4660358" + .to_string(), + dest_path: import_dir + .join("No_Enchant_Glint.zip") + .to_string_lossy() + .to_string(), + }], + ) + .expect("save pending build"); + + let binary_files = session.filesystem_provider.binary_files.clone(); + let metadata = session.filesystem_provider.metadata.clone(); + let writer = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(50)); + binary_files + .lock() + .unwrap() + .insert(exact_variant.clone(), b"manual exact variant".to_vec()); + metadata.lock().unwrap().insert( + exact_variant, + recent_file_metadata("manual exact variant".len()), + ); + }); + + let result = handle_build( + &session, + &BuildArgs { + continue_build: true, + ..Default::default() + }, + ) + .await; + writer.join().expect("join delayed variant writer"); + + assert!( + result.is_ok(), + "interactive wait loop should capture a baseline for legacy pending state: {result:?}" + ); + assert!( + session + .filesystem() + .exists(&pending.restricted_cache_path().join("No_Enchant_Glint.zip")), + "new variant should be imported into the managed restricted cache after baseline capture" + ); + assert!( + crate::empack::restricted_build::load_pending_build(session.filesystem(), &workdir) + .expect("load pending build") + .is_none(), + "pending state should clear after interactive recovery succeeds" + ); + } + #[tokio::test] async fn build_continue_yes_mode_does_not_prompt_or_wait() { let _guard = crate::test_support::env_lock().lock_async().await; diff --git a/crates/empack-lib/src/application/session.rs b/crates/empack-lib/src/application/session.rs index 8b93e73..3695a62 100644 --- a/crates/empack-lib/src/application/session.rs +++ b/crates/empack-lib/src/application/session.rs @@ -679,6 +679,96 @@ impl Default for LiveProcessProvider { } } +fn decode_process_output_chunk(bytes: &[u8]) -> String { + #[cfg(windows)] + { + return decode_process_output_chunk_windows(bytes); + } + + #[cfg(not(windows))] + { + String::from_utf8_lossy(bytes).into_owned() + } +} + +#[cfg(windows)] +fn decode_process_output_chunk_windows(bytes: &[u8]) -> String { + decode_process_output_chunk_windows_with_codepages(bytes, &windows_process_output_codepages()) +} + +#[cfg(windows)] +fn decode_process_output_chunk_windows_with_codepages(bytes: &[u8], codepages: &[u32]) -> String { + if let Ok(valid_utf8) = std::str::from_utf8(bytes) { + return valid_utf8.to_string(); + } + + for codepage in codepages { + if let Some(decoded) = decode_windows_codepage(bytes, *codepage) { + return decoded; + } + } + + String::from_utf8_lossy(bytes).into_owned() +} + +#[cfg(windows)] +fn windows_process_output_codepages() -> Vec { + use windows_sys::Win32::Globalization::{GetACP, GetOEMCP}; + use windows_sys::Win32::System::Console::GetConsoleOutputCP; + + let mut codepages = Vec::new(); + for codepage in unsafe { [GetConsoleOutputCP(), GetACP(), GetOEMCP()] } { + if codepage != 0 && !codepages.contains(&codepage) { + codepages.push(codepage); + } + } + codepages +} + +#[cfg(windows)] +fn decode_windows_codepage(bytes: &[u8], codepage: u32) -> Option { + use windows_sys::Win32::Globalization::MultiByteToWideChar; + + if bytes.is_empty() { + return Some(String::new()); + } + if codepage == 0 { + return None; + } + + let input_len = i32::try_from(bytes.len()).ok()?; + let required_len = unsafe { + MultiByteToWideChar( + codepage, + 0, + bytes.as_ptr(), + input_len, + std::ptr::null_mut(), + 0, + ) + }; + if required_len <= 0 { + return None; + } + + let mut wide = vec![0u16; required_len as usize]; + let converted_len = unsafe { + MultiByteToWideChar( + codepage, + 0, + bytes.as_ptr(), + input_len, + wide.as_mut_ptr(), + required_len, + ) + }; + if converted_len <= 0 { + return None; + } + + String::from_utf16(&wide[..converted_len as usize]).ok() +} + impl ProcessProvider for LiveProcessProvider { fn execute(&self, command: &str, args: &[&str], working_dir: &Path) -> Result { struct NoopProcessObserver; @@ -743,7 +833,7 @@ impl ProcessProvider for LiveProcessProvider { match reader.read_until(b'\n', &mut buf) { Ok(0) => break, Ok(_) => { - let chunk = String::from_utf8_lossy(&buf).into_owned(); + let chunk = decode_process_output_chunk(&buf); if tx.send(ProcessEvent::Chunk(stream, chunk)).is_err() { break; } @@ -1616,8 +1706,11 @@ mod tests { fn write_empack_boundary(root: &Path) { std::fs::create_dir_all(root.join("pack")).expect("create pack dir"); std::fs::write(root.join("empack.yml"), "name: test-pack\n").expect("write empack.yml"); - std::fs::write(root.join("pack").join("pack.toml"), "name = \"test-pack\"\n") - .expect("write pack.toml"); + std::fs::write( + root.join("pack").join("pack.toml"), + "name = \"test-pack\"\n", + ) + .expect("write pack.toml"); } fn write_state_marker(root: &Path) -> std::path::PathBuf { @@ -1759,6 +1852,36 @@ mod tests { assert_eq!(stdout_fallback.error_output(), "stdout failure"); } + #[test] + fn process_output_decoder_prefers_utf8_when_valid() { + let decoded = decode_process_output_chunk("§6No Enchant Glint 1.20.1.zip\n".as_bytes()); + assert_eq!(decoded, "§6No Enchant Glint 1.20.1.zip\n"); + } + + #[cfg(windows)] + #[test] + fn windows_process_output_decoder_preserves_section_sign_from_cp1252() { + let decoded = decode_process_output_chunk_windows_with_codepages( + &[ + 0xA7, b'6', b'N', b'o', b' ', b'E', b'n', b'c', b'h', b'a', b'n', b't', b' ', b'G', + b'l', b'i', b'n', b't', b' ', b'1', b'.', b'2', b'0', b'.', b'1', b'.', b'z', b'i', + b'p', + ], + &[1252], + ); + assert_eq!(decoded, "§6No Enchant Glint 1.20.1.zip"); + } + + #[cfg(windows)] + #[test] + fn windows_process_output_decoder_falls_back_lossily_when_unmappable() { + let decoded = decode_process_output_chunk_windows_with_codepages(&[0xFF, 0xFE], &[0]); + assert!( + !decoded.is_empty(), + "lossy fallback should still return replacement text" + ); + } + #[test] fn live_archive_provider_creates_and_extracts_zip() { let temp = TempDir::new().expect("temp dir"); diff --git a/crates/empack-lib/src/empack/packwiz.test.rs b/crates/empack-lib/src/empack/packwiz.test.rs index 96b7647..ff070ea 100644 --- a/crates/empack-lib/src/empack/packwiz.test.rs +++ b/crates/empack-lib/src/empack/packwiz.test.rs @@ -1548,6 +1548,16 @@ fn test_restricted_destination_filename_extracts_basename() { ); } +#[test] +fn test_restricted_destination_filename_preserves_section_sign() { + assert_eq!( + restricted_destination_filename( + "/tmp/packwiz/cache/import/\u{00A7}6No Enchant Glint 1.20.1.zip" + ), + Some("§6No Enchant Glint 1.20.1.zip".to_string()) + ); +} + #[test] fn test_restricted_curseforge_file_id_supports_files_and_download_urls() { assert_eq!( @@ -1657,6 +1667,27 @@ Once you have done so, place these files in C:\\Users\\test\\AppData\\Local\\pac ); } +#[test] +fn test_parse_export_restricted_output_preserves_deceasedcraft_section_sign_filename() { + let output = "\ +Found 1 manual downloads; these mods are unable to be downloaded by packwiz (due to API limitations) and must be manually downloaded: +No Enchant Glint (§6No Enchant Glint 1.20.1.zip) from https://www.curseforge.com/minecraft/texture-packs/no-enchant-glint/download/4660358 +Once you have done so, place these files in C:\\Users\\test\\AppData\\Local\\packwiz\\cache\\import and re-run this command."; + + let results = parse_export_restricted_output(output); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].name, "No Enchant Glint"); + assert_eq!( + results[0].url, + "https://www.curseforge.com/minecraft/texture-packs/no-enchant-glint/download/4660358" + ); + assert_eq!( + results[0].dest_path, + "C:\\Users\\test\\AppData\\Local\\packwiz\\cache\\import\\§6No Enchant Glint 1.20.1.zip" + ); +} + #[test] fn test_parse_export_restricted_output_accepts_non_http_url_schemes() { let output = "\ diff --git a/crates/empack-lib/src/empack/restricted_build.rs b/crates/empack-lib/src/empack/restricted_build.rs index 6624f99..53c411c 100644 --- a/crates/empack-lib/src/empack/restricted_build.rs +++ b/crates/empack-lib/src/empack/restricted_build.rs @@ -34,6 +34,14 @@ impl PendingRestrictedBuildEntry { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PendingRestrictedCandidateSnapshot { + pub path: String, + pub len: u64, + pub modified_unix_ms: Option, + pub created_unix_ms: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PendingRestrictedBuild { pub schema_version: u32, @@ -43,6 +51,8 @@ pub struct PendingRestrictedBuild { pub restricted_cache_dir: String, #[serde(default)] pub recorded_at_unix_ms: Option, + #[serde(default)] + pub candidate_baseline: Vec, pub entries: Vec, } @@ -125,15 +135,23 @@ pub fn save_pending_build( project_fingerprint: compute_project_fingerprint(provider, workdir)?, restricted_cache_dir: restricted_cache_dir.to_string_lossy().to_string(), recorded_at_unix_ms: Some(current_unix_ms()), + candidate_baseline: Vec::new(), entries, }; - let serialized = serde_json::to_string_pretty(&pending)?; - provider.write_file(&pending_state_path(workdir), &serialized)?; - + persist_pending_build(provider, workdir, &pending)?; Ok(pending) } +pub fn persist_pending_build( + provider: &dyn FileSystemProvider, + workdir: &Path, + pending: &PendingRestrictedBuild, +) -> Result<()> { + let serialized = serde_json::to_string_pretty(pending)?; + provider.write_file(&pending_state_path(workdir), &serialized) +} + pub fn load_pending_build( provider: &dyn FileSystemProvider, workdir: &Path, @@ -234,6 +252,27 @@ pub fn import_matching_downloads_into_cache( "restricted download exact filename match not found; trying recent-file fallback" ); + if !pending.candidate_baseline.is_empty() { + tracing::debug!( + filename = %filename, + baseline_entries = pending.candidate_baseline.len(), + "restricted download using baseline-aware fallback" + ); + + if let Some((hash, candidate)) = find_new_or_changed_candidate( + provider, + entry, + &cache_path, + &search_dirs, + &pending.candidate_baseline, + &used_fallback_hashes, + )? { + import_candidate_into_cache(provider, &candidate, &cache_path)?; + used_fallback_hashes.insert(hash); + } + continue; + } + let Some(recent_cutoff_ms) = recent_cutoff_ms else { tracing::debug!( filename = %filename, @@ -242,6 +281,12 @@ pub fn import_matching_downloads_into_cache( continue; }; + tracing::debug!( + filename = %filename, + recent_cutoff_ms, + "restricted download using legacy recent-file fallback" + ); + if let Some((hash, candidate)) = find_recent_candidate( provider, entry, @@ -333,6 +378,69 @@ fn find_exact_candidate( None } +pub fn capture_candidate_baseline( + provider: &dyn FileSystemProvider, + search_dirs: &[PathBuf], +) -> Result> { + let mut snapshots = Vec::new(); + let mut seen_paths = HashSet::new(); + + for dir in search_dirs { + for candidate in provider + .get_file_list(dir) + .with_context(|| format!("Failed to scan {}", dir.display()))? + { + let candidate_path = candidate.to_string_lossy().into_owned(); + if !seen_paths.insert(candidate_path.clone()) { + continue; + } + + let metadata = match provider.file_metadata(&candidate) { + Ok(metadata) => metadata, + Err(error) => { + tracing::debug!( + path = %candidate.display(), + error = %error, + "skipping baseline candidate with unreadable metadata" + ); + continue; + } + }; + if metadata.is_directory { + continue; + } + + snapshots.push(PendingRestrictedCandidateSnapshot { + path: candidate_path, + len: metadata.len, + modified_unix_ms: metadata.modified_unix_ms, + created_unix_ms: metadata.created_unix_ms, + }); + } + } + + snapshots.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(snapshots) +} + +fn snapshot_changed( + candidate_path: &Path, + metadata: &FileMetadata, + baseline: &[PendingRestrictedCandidateSnapshot], +) -> bool { + let candidate_path = candidate_path.to_string_lossy(); + let Some(snapshot) = baseline + .iter() + .find(|snapshot| snapshot.path == candidate_path) + else { + return true; + }; + + snapshot.len != metadata.len + || snapshot.modified_unix_ms != metadata.modified_unix_ms + || snapshot.created_unix_ms != metadata.created_unix_ms +} + fn find_recent_candidate( provider: &dyn FileSystemProvider, entry: &PendingRestrictedBuildEntry, @@ -343,6 +451,7 @@ fn find_recent_candidate( ) -> Result> { let expected_extension = lowercase_extension(Path::new(&entry.filename)); let mut candidates_by_hash = HashMap::new(); + let mut eligible_candidate_paths = Vec::new(); for dir in search_dirs { for candidate in provider @@ -384,6 +493,7 @@ fn find_recent_candidate( continue; } + eligible_candidate_paths.push(candidate.display().to_string()); let hash = match candidate_sha256(provider, &candidate) { Ok(hash) => hash, Err(error) => { @@ -402,10 +512,16 @@ fn find_recent_candidate( } } + let distinct_candidate_paths: Vec = candidates_by_hash + .values() + .map(|candidate| candidate.display().to_string()) + .collect(); tracing::debug!( filename = %entry.filename, + eligible_candidate_count = eligible_candidate_paths.len(), candidate_count = candidates_by_hash.len(), recent_cutoff_ms, + ?distinct_candidate_paths, "restricted download recent-file fallback evaluated" ); @@ -427,6 +543,7 @@ fn find_recent_candidate( tracing::debug!( filename = %entry.filename, candidate_count = candidates_by_hash.len(), + ?distinct_candidate_paths, "restricted download fallback skipped due to ambiguous recent candidates" ); Ok(None) @@ -434,6 +551,107 @@ fn find_recent_candidate( } } +fn find_new_or_changed_candidate( + provider: &dyn FileSystemProvider, + entry: &PendingRestrictedBuildEntry, + cache_path: &Path, + search_dirs: &[PathBuf], + baseline: &[PendingRestrictedCandidateSnapshot], + used_fallback_hashes: &HashSet, +) -> Result> { + let expected_extension = lowercase_extension(Path::new(&entry.filename)); + let mut candidates_by_hash = HashMap::new(); + let mut eligible_candidate_paths = Vec::new(); + + for dir in search_dirs { + for candidate in provider + .get_file_list(dir) + .with_context(|| format!("Failed to scan {}", dir.display()))? + { + if candidate == cache_path { + continue; + } + + let metadata = match provider.file_metadata(&candidate) { + Ok(metadata) => metadata, + Err(error) => { + tracing::debug!( + path = %candidate.display(), + error = %error, + "skipping restricted download candidate with unreadable metadata" + ); + continue; + } + }; + if metadata.is_directory { + continue; + } + + if lowercase_extension(&candidate) != expected_extension { + continue; + } + + if !snapshot_changed(&candidate, &metadata, baseline) { + continue; + } + + eligible_candidate_paths.push(candidate.display().to_string()); + let hash = match candidate_sha256(provider, &candidate) { + Ok(hash) => hash, + Err(error) => { + tracing::debug!( + path = %candidate.display(), + error = %error, + "skipping restricted download candidate that could not be hashed" + ); + continue; + } + }; + if used_fallback_hashes.contains(&hash) { + continue; + } + candidates_by_hash.entry(hash).or_insert(candidate); + } + } + + let distinct_candidate_paths: Vec = candidates_by_hash + .values() + .map(|candidate| candidate.display().to_string()) + .collect(); + tracing::debug!( + filename = %entry.filename, + eligible_candidate_count = eligible_candidate_paths.len(), + candidate_count = candidates_by_hash.len(), + ?distinct_candidate_paths, + "restricted download baseline-aware fallback evaluated" + ); + + match candidates_by_hash.len() { + 0 => Ok(None), + 1 => { + let (hash, candidate) = candidates_by_hash + .into_iter() + .next() + .expect("single candidate"); + tracing::debug!( + filename = %entry.filename, + candidate = %candidate.display(), + "restricted download baseline-aware fallback selected a unique candidate" + ); + Ok(Some((hash, candidate))) + } + _ => { + tracing::debug!( + filename = %entry.filename, + candidate_count = candidates_by_hash.len(), + ?distinct_candidate_paths, + "restricted download baseline-aware fallback skipped due to ambiguous candidates" + ); + Ok(None) + } + } +} + fn import_candidate_into_cache( provider: &dyn FileSystemProvider, candidate: &Path, diff --git a/crates/empack-lib/src/empack/restricted_build.test.rs b/crates/empack-lib/src/empack/restricted_build.test.rs index c14e8c5..dc950c2 100644 --- a/crates/empack-lib/src/empack/restricted_build.test.rs +++ b/crates/empack-lib/src/empack/restricted_build.test.rs @@ -91,6 +91,30 @@ fn sample_resourcepack_restricted_mod(workdir: &Path) -> RestrictedModInfo { } } +fn sample_deceasedcraft_resourcepack_restricted_mod(workdir: &Path) -> RestrictedModInfo { + RestrictedModInfo { + name: "No Enchant Glint".to_string(), + url: "https://www.curseforge.com/minecraft/texture-packs/no-enchant-glint/download/4660358" + .to_string(), + dest_path: workdir + .join("dist") + .join("client-full") + .join("resourcepacks") + .join("§6No Enchant Glint 1.20.1.zip") + .to_string_lossy() + .to_string(), + } +} + +fn baseline_snapshot(path: PathBuf, metadata: &FileMetadata) -> PendingRestrictedCandidateSnapshot { + PendingRestrictedCandidateSnapshot { + path: path.to_string_lossy().to_string(), + len: metadata.len, + modified_unix_ms: metadata.modified_unix_ms, + created_unix_ms: metadata.created_unix_ms, + } +} + #[test] fn save_and_load_pending_build_round_trips() { let _guard = crate::test_support::env_lock().lock().unwrap(); @@ -119,6 +143,87 @@ fn save_and_load_pending_build_round_trips() { assert_eq!(loaded.targets, vec!["client-full"]); assert_eq!(loaded.entries.len(), 2); assert_eq!(loaded.entries[0].filename, "entityculling.jar"); + assert!(loaded.candidate_baseline.is_empty()); +} + +#[test] +fn persist_pending_build_round_trips_candidate_baseline() { + let _guard = crate::test_support::env_lock().lock().unwrap(); + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("restricted-build-baseline-roundtrip"); + let downloads_dir = workdir.join("downloads"); + let provider = MockFileSystemProvider::new() + .with_current_dir(workdir.clone()) + .with_configured_project(workdir.clone()) + .with_binary_file_and_metadata( + downloads_dir.join("existing.zip"), + b"existing bytes".to_vec(), + recent_file_metadata("existing bytes".len(), 123_456), + ); + + let mut pending = save_pending_build( + &provider, + &workdir, + &[BuildTarget::ClientFull], + ArchiveFormat::Zip, + &[sample_resourcepack_restricted_mod(&workdir)], + ) + .expect("save pending build"); + pending.candidate_baseline = + capture_candidate_baseline(&provider, std::slice::from_ref(&downloads_dir)) + .expect("capture baseline"); + + persist_pending_build(&provider, &workdir, &pending).expect("persist pending build"); + let loaded = load_pending_build(&provider, &workdir) + .expect("load pending build") + .expect("pending build exists"); + + assert_eq!(loaded, pending); + assert_eq!(loaded.candidate_baseline.len(), 1); +} + +#[test] +fn load_pending_build_defaults_missing_candidate_baseline() { + let _guard = crate::test_support::env_lock().lock().unwrap(); + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("restricted-build-missing-baseline"); + let provider = MockFileSystemProvider::new() + .with_current_dir(workdir.clone()) + .with_configured_project(workdir.clone()); + + let pending = save_pending_build( + &provider, + &workdir, + &[BuildTarget::ClientFull], + ArchiveFormat::Zip, + &[sample_resourcepack_restricted_mod(&workdir)], + ) + .expect("save pending build"); + + let state_path = pending_state_path(&workdir); + let mut json: serde_json::Value = + serde_json::from_str(&provider.read_to_string(&state_path).expect("read pending json")) + .expect("parse pending json"); + json.as_object_mut() + .expect("pending json object") + .remove("candidate_baseline"); + provider + .write_file( + &state_path, + &serde_json::to_string_pretty(&json).expect("serialize pending json"), + ) + .expect("rewrite pending json"); + + let loaded = load_pending_build(&provider, &workdir) + .expect("load pending build") + .expect("pending build exists"); + + assert!(loaded.candidate_baseline.is_empty()); + assert_eq!(loaded.entries, pending.entries); } #[test] @@ -346,6 +451,50 @@ fn import_matching_downloads_into_cache_imports_recent_unicode_named_zip_when_ex ); } +#[test] +fn import_matching_downloads_into_cache_imports_exact_deceasedcraft_filename_when_present() { + let _guard = crate::test_support::env_lock().lock().unwrap(); + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("restricted-build-exact-deceasedcraft"); + let downloads_dir = workdir.join("downloads"); + let exact_name = "§6No Enchant Glint 1.20.1.zip"; + let bytes = b"exact deceasedcraft bytes".to_vec(); + let provider = MockFileSystemProvider::new() + .with_current_dir(workdir.clone()) + .with_configured_project(workdir.clone()) + .with_binary_file_and_metadata( + downloads_dir.join(exact_name), + bytes.clone(), + recent_file_metadata(bytes.len(), 205_000), + ); + + let pending = save_pending_build( + &provider, + &workdir, + &[BuildTarget::ClientFull], + ArchiveFormat::Zip, + &[sample_deceasedcraft_resourcepack_restricted_mod(&workdir)], + ) + .expect("save pending build"); + + import_matching_downloads_into_cache( + &provider, + &workdir, + &pending, + std::slice::from_ref(&downloads_dir), + ) + .expect("import exact deceasedcraft filename"); + + assert_eq!( + provider + .read_bytes(&pending.restricted_cache_path().join(exact_name)) + .expect("read cached exact file"), + bytes + ); +} + #[test] fn import_matching_downloads_into_cache_ignores_old_unicode_zip_candidates() { let _guard = crate::test_support::env_lock().lock().unwrap(); @@ -529,6 +678,130 @@ fn import_matching_downloads_into_cache_does_not_guess_when_multiple_distinct_re assert_eq!(missing_cached_entries(&provider, &pending).len(), 1); } +#[test] +fn import_matching_downloads_into_cache_ignores_preexisting_recent_zip_noise_when_baseline_exists() { + let _guard = crate::test_support::env_lock().lock().unwrap(); + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("restricted-build-baseline-noise"); + let downloads_dir = workdir.join("downloads"); + let noise_a = downloads_dir.join("noise-a.zip"); + let noise_b = downloads_dir.join("noise-b.zip"); + let noise_c = downloads_dir.join("noise-c.zip"); + let target_path = downloads_dir.join("§6No Enchant Glint 1.20.1.zip"); + let noise_a_meta = recent_file_metadata(7, 205_000); + let noise_b_meta = recent_file_metadata(7, 206_000); + let noise_c_meta = recent_file_metadata(7, 207_000); + let provider = MockFileSystemProvider::new() + .with_current_dir(workdir.clone()) + .with_configured_project(workdir.clone()) + .with_binary_file_and_metadata(noise_a.clone(), b"noise-a".to_vec(), noise_a_meta.clone()) + .with_binary_file_and_metadata(noise_b.clone(), b"noise-b".to_vec(), noise_b_meta.clone()) + .with_binary_file_and_metadata(noise_c.clone(), b"noise-c".to_vec(), noise_c_meta.clone()); + + let mut pending = save_pending_build( + &provider, + &workdir, + &[BuildTarget::ClientFull], + ArchiveFormat::Zip, + &[sample_deceasedcraft_resourcepack_restricted_mod(&workdir)], + ) + .expect("save pending build"); + pending.candidate_baseline = vec![ + baseline_snapshot(noise_a, &noise_a_meta), + baseline_snapshot(noise_b, &noise_b_meta), + baseline_snapshot(noise_c, &noise_c_meta), + ]; + + provider + .write_bytes(&target_path, b"manual resource pack bytes") + .expect("write target file"); + provider.set_file_metadata( + target_path, + recent_file_metadata("manual resource pack bytes".len(), 208_000), + ); + + import_matching_downloads_into_cache( + &provider, + &workdir, + &pending, + std::slice::from_ref(&downloads_dir), + ) + .expect("import with baseline noise"); + + assert_eq!( + provider + .read_bytes( + &pending + .restricted_cache_path() + .join("§6No Enchant Glint 1.20.1.zip") + ) + .expect("read cached target"), + b"manual resource pack bytes" + ); +} + +#[test] +fn import_matching_downloads_into_cache_keeps_blocking_when_multiple_new_distinct_zip_candidates_exist_after_baseline( +) { + let _guard = crate::test_support::env_lock().lock().unwrap(); + let cache_root = TempDir::new().expect("cache root tempdir"); + let _cache_dir = unsafe { EnvVarGuard::set("EMPACK_CACHE_DIR", cache_root.path()) }; + + let workdir = mock_root().join("restricted-build-baseline-ambiguous"); + let downloads_dir = workdir.join("downloads"); + let baseline_path = downloads_dir.join("preexisting.zip"); + let baseline_meta = recent_file_metadata("baseline".len(), 200_000); + let provider = MockFileSystemProvider::new() + .with_current_dir(workdir.clone()) + .with_configured_project(workdir.clone()) + .with_binary_file_and_metadata( + baseline_path.clone(), + b"baseline".to_vec(), + baseline_meta.clone(), + ); + + let mut pending = save_pending_build( + &provider, + &workdir, + &[BuildTarget::ClientFull], + ArchiveFormat::Zip, + &[sample_resourcepack_restricted_mod(&workdir)], + ) + .expect("save pending build"); + pending.candidate_baseline = vec![baseline_snapshot(baseline_path, &baseline_meta)]; + + let first = downloads_dir.join("§6No Enchant Glint 1.20.1.zip"); + let second = downloads_dir.join("Another recent resource pack.zip"); + provider + .write_bytes(&first, b"first bytes") + .expect("write first candidate"); + provider.set_file_metadata(first, recent_file_metadata("first bytes".len(), 205_000)); + provider + .write_bytes(&second, b"second bytes") + .expect("write second candidate"); + provider.set_file_metadata(second, recent_file_metadata("second bytes".len(), 206_000)); + + import_matching_downloads_into_cache( + &provider, + &workdir, + &pending, + std::slice::from_ref(&downloads_dir), + ) + .expect("scan ambiguous post-baseline candidates"); + + assert!( + !provider.exists( + &pending + .restricted_cache_path() + .join("No_Enchant_Glint.zip") + ), + "multiple new distinct candidates should remain ambiguous" + ); + assert_eq!(missing_cached_entries(&provider, &pending).len(), 1); +} + #[test] fn import_matching_downloads_into_cache_scans_managed_cache_for_recent_variant_names() { let _guard = crate::test_support::env_lock().lock().unwrap();