Browse / github.com/storytold/craft-launcher / patch
portable: install a ZIP that wraps its program in one folder
portable: install a ZIP that wraps its program in one folder Fixes storytold/artcraft#2461, where a portable install fails with > The portable ZIP does not contain the expected executable at its root. ## Cause `crates/native/src/portable.rs` looks for the executable only at the ZIP root: ```rust .find(|relative| { relative.parent().is_some_and(|p| p.as_os_str().is_empty()) && relative.file_name().and_then(|n| n.to_str()) .is_some_and(|name| app.stems().any(|stem| name.eq_ignore_ascii_case(&format!("{stem}.exe")))) }) .ok_or("The portable ZIP does not contain the expected executable at its root.")? ``` A release ZIP almost al
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Hamza0101 (imported from pull request #627, open)
- Base
7c7271e5f62975326d0add2dcf31f03096be4189- Size
- +134 −11 in 3 files
- Review
- pending
- Shared
- 2026-10-28
- Id
67628caa3d5f28eee823c600
How it went
No other operator has reported using it yet.
Diff
raw · crates/native/src/lib.rs crates/native/src/portable.rs crates/native/src/portable_layout.rs
@@ -19,6 +19,7 @@ pub mod packages; pub mod paths; #[cfg(windows)] pub mod portable;+pub mod portable_layout; pub mod process; pub mod proxy; pub mod queue;@@ -72,15 +72,6 @@ fn resolve_missing(path: &Path) -> Result<PathBuf, String> { Err("The installation folder has no existing ancestor.".into()) } -fn same_windows_path(left: &Path, right: &Path) -> bool {- fn normalized(path: &Path) -> String {- let text = path.to_string_lossy();- let text = text.strip_prefix(r"\\?\").unwrap_or(&text);- text.trim_end_matches(['\\', '/']).to_lowercase()- }- normalized(left) == normalized(right)-}- fn linked(meta: &std::fs::Metadata) -> bool { #[cfg(windows)] {@@ -149,7 +140,7 @@ fn recorded_files<'a>(app: &CraftApp, record: &'a Record) -> Result<(&'a Path, V return Err("The launcher has no valid ownership record for this portable folder.".into()); } let resolved = resolve_missing(root)?;- if !same_windows_path(root, &resolved) {+ if !crate::portable_layout::same_windows_path(root, &resolved) { return Err("The recorded portable folder resolves through a changed alias. Reinstall it to choose a safe location.".into()); } validate_owned_directory(root, root)?;@@ -205,6 +196,9 @@ 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)?;+ // A release ZIP usually wraps everything in one folder (`VectorCraft-1.2.3/vectorcraft.exe`).+ // Install what is inside it, so such a ZIP lands the same way as one packed without it.+ let (unpacked, files) = crate::portable_layout::unwrap_single_folder(unpacked, files); let executable = files .iter() .find(|relative| {@@ -221,7 +215,7 @@ pub fn install( let target = destination.join(relative); validate_owned_directory(&destination, target.parent().ok_or("no destination folder")?)?; if let Ok(meta) = std::fs::symlink_metadata(&target) {- let recorded = old_files.iter().any(|old| same_windows_path(old, relative));+ let recorded = old_files.iter().any(|old| crate::portable_layout::same_windows_path(old, relative)); if linked(&meta) || !meta.is_file() || !(recorded || replace) { return Err("The installation would overwrite an unrecorded file.".into()); }new file mode 100644@@ -0,0 +1,128 @@+//! What a portable ZIP's own layout means, apart from installing it: pure path reasoning, so it+//! is compiled and tested on every platform while [`crate::portable`] stays Windows-only.++use std::path::{Path, PathBuf};++/// Whether two paths name the same file to Windows: case is ignored, a trailing separator does+/// not count, and the `\\?\` prefix is not part of the name.+pub fn same_windows_path(left: &Path, right: &Path) -> bool {+ fn normalized(path: &Path) -> String {+ let text = path.to_string_lossy();+ let text = text.strip_prefix(r"\\?\").unwrap_or(&text);+ text.trim_end_matches(['\\', '/']).to_lowercase()+ }+ normalized(left) == normalized(right)+}++/// The one folder a ZIP wraps everything in, when it has one: every entry lies inside it, and no+/// entry sits beside it at the root. `None` for a ZIP whose files are already at the root, or one+/// with several top-level entries, where there is nothing unambiguous to strip.+///+/// Entries are compared as Windows names them, so `App/app.exe` and `app/lib.dll` are one folder:+/// that is the folder they extract into on a case-insensitive volume, and `extract` already+/// refuses a ZIP whose names collide only in case.+fn wrapper_folder(files: &[PathBuf]) -> Option<PathBuf> {+ let mut folders = files.iter().map(|relative| {+ let mut components = relative.components();+ match (components.next(), components.next()) {+ // A first component with something below it: the candidate folder.+ (Some(first), Some(_)) => Some(PathBuf::from(first.as_os_str())),+ // A file at the root: nothing may be stripped.+ _ => None,+ }+ });+ let first = folders.next().flatten()?;+ folders.all(|folder| folder.as_deref().is_some_and(|f| same_windows_path(f, &first))).then_some(first)+}++/// `unpacked` and the entries inside it, with one wrapping folder dropped when the ZIP has one:+/// the folder becomes the place the files are read from, and the entries lose its name, so a ZIP+/// that wraps its program installs exactly as one packed without the wrapper does.+pub fn unwrap_single_folder(unpacked: PathBuf, files: Vec<PathBuf>) -> (PathBuf, Vec<PathBuf>) {+ let Some(folder) = wrapper_folder(&files) else { return (unpacked, files) };+ // The first component goes by position, not by name: `strip_prefix` compares exactly, and the+ // entries may spell the folder in another case than the one chosen above.+ let inside = files.iter().map(|entry| entry.components().skip(1).collect()).collect();+ (unpacked.join(folder), inside)+}++#[cfg(test)]+mod tests {+ use super::*;++ /// A release ZIP that wraps everything in one folder installs what is inside it, so the+ /// program lands flat rather than being refused for having no executable at the root.+ #[test]+ fn one_wrapping_folder_is_stripped_and_nothing_else_is() {+ let p = |s: &str| PathBuf::from(s);+ // Everything inside one folder: that folder is the wrapper.+ let wrapped = [p("VectorCraft-1.2.3/vectorcraft.exe"), p("VectorCraft-1.2.3/data/fonts.bin")];+ assert_eq!(wrapper_folder(&wrapped), Some(p("VectorCraft-1.2.3")));++ // Already flat: there is nothing to strip, and the executable is found as before.+ assert_eq!(wrapper_folder(&[p("vectorcraft.exe"), p("data/fonts.bin")]), None);+ assert_eq!(wrapper_folder(&[p("vectorcraft.exe")]), None);++ // A file beside the folder at the root: stripping would lose it.+ assert_eq!(wrapper_folder(&[p("VectorCraft-1.2.3/vectorcraft.exe"), p("README.txt")]), None);++ // Two top-level folders: nothing unambiguous to strip.+ assert_eq!(wrapper_folder(&[p("a/vectorcraft.exe"), p("b/fonts.bin")]), None);++ // An empty list strips nothing.+ assert_eq!(wrapper_folder(&[]), None);++ // Only the outermost folder goes, however deep the tree is.+ let deep = [p("App/bin/app.exe"), p("App/bin/lib/x.dll")];+ assert_eq!(wrapper_folder(&deep), Some(p("App")));+ let stripped: Vec<PathBuf> = deep.iter().map(|f| f.strip_prefix("App").unwrap().to_path_buf()).collect();+ assert_eq!(wrapper_folder(&stripped), Some(p("bin")), "the next level is only stripped if asked for again");+ }++ /// Windows treats `App` and `app` as one folder, and a ZIP spelling the wrapper both ways+ /// extracts into one folder there, so it is one wrapper here too. `extract` already refuses a+ /// ZIP whose names collide only in case, so this cannot hide two real folders.+ #[test]+ fn a_wrapper_spelled_in_different_cases_is_one_folder() {+ let p = |s: &str| PathBuf::from(s);+ let mixed = [p("App-1.2.3/photocraft.exe"), p("app-1.2.3/lib.dll"), p("APP-1.2.3/data/x.bin")];+ assert_eq!(wrapper_folder(&mixed), Some(p("App-1.2.3")), "the first spelling names it");++ // And the entries lose it however they spell it, so the executable reaches the root.+ let (from, files) = unwrap_single_folder(p("/staging/unpacked"), mixed.to_vec());+ assert_eq!(from, p("/staging/unpacked/App-1.2.3"));+ assert_eq!(files, vec![p("photocraft.exe"), p("lib.dll"), p("data/x.bin")]);++ // Genuinely different folders are still different.+ assert_eq!(wrapper_folder(&[p("App/a.exe"), p("Apple/b.dll")]), None);+ }++ #[test]+ fn windows_names_the_same_file_whatever_the_case_or_trailing_slash() {+ let p = |s: &str| PathBuf::from(s);+ assert!(same_windows_path(&p("App/app.exe"), &p("app/APP.EXE")));+ assert!(same_windows_path(&p("App"), &p("App/")));+ assert!(!same_windows_path(&p("App"), &p("Apple")));+ }++ /// What the installer uses: where to read the files from, and what to call them.+ #[test]+ fn unwrapping_moves_the_source_folder_and_the_entry_names_together() {+ let p = |s: &str| PathBuf::from(s);+ let unpacked = p("/staging/unpacked");++ let (from, files) = unwrap_single_folder(unpacked.clone(), vec![p("App-1.2.3/app.exe"), p("App-1.2.3/data/fonts.bin")]);+ assert_eq!(from, p("/staging/unpacked/App-1.2.3"), "read from inside the wrapper");+ assert_eq!(files, vec![p("app.exe"), p("data/fonts.bin")], "installed without it");+ // The executable is now at the root, which is what the installer looks for.+ assert!(files.iter().any(|f| f.parent().is_some_and(|parent| parent.as_os_str().is_empty()) && f.ends_with("app.exe")));++ // A flat ZIP is untouched, as are the ambiguous layouts.+ let flat = vec![p("app.exe"), p("data/fonts.bin")];+ assert_eq!(unwrap_single_folder(unpacked.clone(), flat.clone()), (unpacked.clone(), flat));+ let two = vec![p("a/app.exe"), p("b/fonts.bin")];+ assert_eq!(unwrap_single_folder(unpacked.clone(), two.clone()), (unpacked.clone(), two));+ let beside = vec![p("App/app.exe"), p("README.txt")];+ assert_eq!(unwrap_single_folder(unpacked.clone(), beside.clone()), (unpacked, beside));+ }+}