diff --git a/Cargo.lock b/Cargo.lock index e4caa59cb..56850b223 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5106,6 +5106,7 @@ dependencies = [ "tracing", "url", "walkdir", + "winreg", "zip", "zstd", ] @@ -8749,6 +8750,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "winver" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 9e14e7220..4aad8a7a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,7 @@ urlencoding = "2.1" walkdir = "2.5.0" which = "8.0.2" windows-sys = "0.61" +winreg = "0.56" zip = { version = "8.5.1", default-features = false, features = ["deflate"] } zstd = "0.13.3" spdx = "0.13.4" diff --git a/crates/rattler_build_source_cache/Cargo.toml b/crates/rattler_build_source_cache/Cargo.toml index 91ed5e44e..8314854e6 100644 --- a/crates/rattler_build_source_cache/Cargo.toml +++ b/crates/rattler_build_source_cache/Cargo.toml @@ -63,6 +63,9 @@ sigstore-verify = { version = "0.6.4", default-features = false, optional = true sigstore-trust-root = { version = "0.6.4", default-features = false, optional = true } sigstore-types = { version = "0.6.4", optional = true } +[target.'cfg(target_os = "windows")'.dependencies] +winreg = { workspace = true } + [dev-dependencies] tokio = { workspace = true, features = ["rt", "macros", "test-util"] } tempfile.workspace = true diff --git a/crates/rattler_build_source_cache/src/cache.rs b/crates/rattler_build_source_cache/src/cache.rs index 6802e3b6e..751c36338 100644 --- a/crates/rattler_build_source_cache/src/cache.rs +++ b/crates/rattler_build_source_cache/src/cache.rs @@ -711,7 +711,41 @@ pub fn is_tarball(file_name: &str) -> bool { .any(|ext| file_name.ends_with(ext)) } -fn extract_tar(archive: &Path, target: &Path) -> Result<(), CacheError> { +/// On Windows, checks whether Developer Mode is enabled by reading the registry key +/// `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock` value +/// `AllowDevelopmentWithoutDevLicense`. +/// Returns `false` if the key is missing, inaccessible, or the value is not `1`. +#[cfg(target_os = "windows")] +fn is_windows_developer_mode_enabled() -> bool { + use winreg::RegKey; + use winreg::enums::HKEY_LOCAL_MACHINE; + + (|| -> Result { + let value: u32 = RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey(r"SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock")? + .get_value("AllowDevelopmentWithoutDevLicense")?; + Ok(value == 1) + })() + .unwrap_or(false) +} + +/// Wraps a tar unpack error, appending a Windows Developer Mode hint when on Windows and +/// Developer Mode is detected as disabled. +fn tar_unpack_error(context: &str, e: std::io::Error) -> CacheError { + #[cfg(target_os = "windows")] + if !is_windows_developer_mode_enabled() { + return CacheError::ExtractionError(format!( + "{context}: {e}\n\n\ + hint: Extracting this archive failed on Windows. The archive may contain symbolic \ + links, which require Developer Mode or administrator privileges to create. \ + Please enable Developer Mode in: \ + Developer settings > Developer Mode, then retry the build." + )); + } + CacheError::ExtractionError(format!("{context}: {e}")) +} + +pub(crate) fn extract_tar(archive: &Path, target: &Path) -> Result<(), CacheError> { let file = fs_err::File::open(archive) .map_err(|e| CacheError::ExtractionError(format!("Failed to open archive: {}", e)))?; @@ -721,30 +755,30 @@ fn extract_tar(archive: &Path, target: &Path) -> Result<(), CacheError> { let mut archive = tar::Archive::new(GzDecoder::new(file)); archive .unpack(target) - .map_err(|e| CacheError::ExtractionError(format!("Failed to extract tar.gz: {}", e)))?; + .map_err(|e| tar_unpack_error("Failed to extract tar.gz", e))?; } else if name.ends_with(".tar.bz2") || name.ends_with(".tbz2") { let mut archive = tar::Archive::new(bzip2::read::BzDecoder::new(file)); - archive.unpack(target).map_err(|e| { - CacheError::ExtractionError(format!("Failed to extract tar.bz2: {}", e)) - })?; + archive + .unpack(target) + .map_err(|e| tar_unpack_error("Failed to extract tar.bz2", e))?; } else if name.ends_with(".tar.xz") || name.ends_with(".txz") { let mut archive = tar::Archive::new(lzma_rust2::XzReader::new(file, true)); archive .unpack(target) - .map_err(|e| CacheError::ExtractionError(format!("Failed to extract tar.xz: {}", e)))?; + .map_err(|e| tar_unpack_error("Failed to extract tar.xz", e))?; } else if name.ends_with(".tar.zst") { let decoder = zstd::stream::read::Decoder::new(file).map_err(|e| { CacheError::ExtractionError(format!("Failed to create zstd decoder: {}", e)) })?; let mut archive = tar::Archive::new(decoder); - archive.unpack(target).map_err(|e| { - CacheError::ExtractionError(format!("Failed to extract tar.zst: {}", e)) - })?; + archive + .unpack(target) + .map_err(|e| tar_unpack_error("Failed to extract tar.zst", e))?; } else { let mut archive = tar::Archive::new(file); archive .unpack(target) - .map_err(|e| CacheError::ExtractionError(format!("Failed to extract tar: {}", e)))?; + .map_err(|e| tar_unpack_error("Failed to extract tar", e))?; } Ok(()) diff --git a/crates/rattler_build_source_cache/src/tests.rs b/crates/rattler_build_source_cache/src/tests.rs index c318a345c..2c0478814 100644 --- a/crates/rattler_build_source_cache/src/tests.rs +++ b/crates/rattler_build_source_cache/src/tests.rs @@ -344,6 +344,55 @@ mod source_cache_tests { ); } + /// Creates a minimal `.tar.gz` at `dest` containing a single symlink entry. + #[cfg(target_os = "windows")] + fn make_tar_gz_with_symlink(dest: &std::path::Path) { + use flate2::{Compression, write::GzEncoder}; + + let file = fs_err::File::create(dest).unwrap(); + let enc = GzEncoder::new(file, Compression::default()); + let mut builder = tar::Builder::new(enc); + + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_cksum(); + builder + .append_link(&mut header, "link_target", "real_file") + .unwrap(); + builder.finish().unwrap(); + } + + /// On Windows without Developer Mode the extraction of a symlink-containing + /// archive should fail with a hint about enabling Developer Mode. + /// On Windows with Developer Mode enabled the extraction succeeds. + #[test] + #[cfg(target_os = "windows")] + fn test_symlink_extraction_error_message() { + use crate::cache::extract_tar; + + let dir = TempDir::new().unwrap(); + let archive = dir.path().join("test.tar.gz"); + make_tar_gz_with_symlink(&archive); + + let target = dir.path().join("out"); + fs_err::create_dir_all(&target).unwrap(); + + match extract_tar(&archive, &target) { + Ok(()) => { + // Symlinks extracted fine — Developer Mode is on (or not Windows) + } + Err(CacheError::ExtractionError(msg)) => { + assert!( + msg.contains("Developer Mode"), + "Expected Developer Mode hint in error message, got: {msg}" + ); + } + Err(e) => panic!("Unexpected error variant: {e}"), + } + } + #[test] fn test_extract_filename_from_header_strips_path_components() { use crate::cache::extract_filename_from_header; diff --git a/py-rattler-build/rust/Cargo.lock b/py-rattler-build/rust/Cargo.lock index bc4b04d19..36be17c7c 100644 --- a/py-rattler-build/rust/Cargo.lock +++ b/py-rattler-build/rust/Cargo.lock @@ -4954,6 +4954,7 @@ dependencies = [ "tracing", "url", "walkdir", + "winreg", "zip", "zstd", ] @@ -8456,6 +8457,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "winver" version = "1.0.0"