From 6fbc078422c5dc8ee8e16da24eb1853bf20fedca Mon Sep 17 00:00:00 2001 From: Hasan Tan <53288599+iamhasantan@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:27:11 +1100 Subject: [PATCH] fix(windows): install version-folder wrapped portable ZIP releases --- crates/native/src/portable.rs | 64 +++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/crates/native/src/portable.rs b/crates/native/src/portable.rs index f43db2bb..db58e42a 100644 --- a/crates/native/src/portable.rs +++ b/crates/native/src/portable.rs @@ -205,6 +205,31 @@ pub fn install( let unpacked = staging.0.join("unpacked"); std::fs::create_dir(&unpacked).map_err(|e| e.to_string())?; let files = extract(file, &unpacked)?; + // The official Windows packaging script uses Compress-Archive -Path $Portable, + // which places all files inside a single versioned directory in the ZIP. + // Accept that layout and traditional flat archives, but never arbitrary nesting. + let has_root_executable = files.iter().any(|relative| { + relative.parent().is_some_and(|parent| parent.as_os_str().is_empty()) + && relative + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| app.stems().any(|stem| name.eq_ignore_ascii_case(&format!("{stem}.exe")))) + }); + let (unpacked, files) = if has_root_executable { + (unpacked, files) + } else { + let prefix = files.first().and_then(|path| path.components().next()).ok_or("The portable ZIP contains no files.")?.as_os_str().to_os_string(); + let prefix = PathBuf::from(prefix); + if !files.iter().all(|path| path.strip_prefix(&prefix).is_ok_and(|relative| relative.components().next().is_some())) { + return Err("The portable ZIP has an unsupported directory layout.".into()); + } + let unpacked = unpacked.join(&prefix); + let files: Vec = files + .into_iter() + .map(|path| path.strip_prefix(&prefix).map(Path::to_path_buf).map_err(|e| e.to_string())) + .collect::, _>>()?; + (unpacked, files) + }; let executable = files .iter() .find(|relative| { @@ -471,6 +496,45 @@ mod tests { assert_eq!(std::fs::read(folder.join("notes.txt")).unwrap(), b"keep"); } + #[test] + fn installs_official_single_directory_portable_zip() { + let dir = tempfile::tempdir().unwrap(); + let app = CraftApp::by_id("photocraft").unwrap(); + let paths = Paths::under(dir.path()); + let zip = archive( + dir.path(), + "release.zip", + &[ + ("photocraft-0.6.1-windows-x64-portable/photocraft.exe", b"program"), + ("photocraft-0.6.1-windows-x64-portable/portable.txt", b""), + ("photocraft-0.6.1-windows-x64-portable/lib/runtime.dll", b"library"), + ], + ); + let record = install(app, &zip, "0.6.1", None, &paths, None, false, &mut |_| Ok(())).unwrap(); + assert_eq!(record.path.file_name().unwrap(), "photocraft.exe"); + assert_eq!(record.path.parent().unwrap().file_name().unwrap(), "photocraft"); + assert_eq!(std::fs::read(&record.path).unwrap(), b"program"); + assert_eq!(std::fs::read(paths.apps().join("photocraft/lib/runtime.dll")).unwrap(), b"library"); + assert_eq!(record.files.len(), 3); + remove(app, &record).unwrap(); + assert!(!record.path.exists()); + } + + #[test] + fn rejects_mixed_root_and_multiple_directory_archives() { + let dir = tempfile::tempdir().unwrap(); + let app = CraftApp::by_id("photocraft").unwrap(); + let paths = Paths::under(dir.path()); + for (name, entries) in [ + ("mixed.zip", vec![("package/photocraft.exe", b"program".as_slice()), ("another/readme.txt", b"other".as_slice())]), + ("nested.zip", vec![("wrapper/inner/photocraft.exe", b"program".as_slice())]), + ] { + let zip = archive(dir.path(), name, &entries); + assert!(install(app, &zip, "0.6.1", None, &paths, None, false, &mut |_| Ok(())).is_err(), "{name}"); + assert!(!paths.apps().join("photocraft/photocraft.exe").exists()); + } + } + #[test] fn rejects_traversal_and_case_collisions() { let dir = tempfile::tempdir().unwrap();