From 9947363ad79ac7ffc7c86a9b14abc1ebc2a5df59 Mon Sep 17 00:00:00 2001 From: buaabw <2725238326@qq.com> Date: Thu, 22 Oct 2026 15:31:32 +0800 Subject: [PATCH 1/5] fix(windows): repair icons on legacy ChatGPT shortcuts --- crates/codex-win-engine/src/portable.rs | 272 ++++++++++++++++++ .../codex-win-engine/src/portable_shortcut.rs | 192 ++++++++++++- 2 files changed, 455 insertions(+), 9 deletions(-) diff --git a/crates/codex-win-engine/src/portable.rs b/crates/codex-win-engine/src/portable.rs index 014ab8b..dcc4f97 100644 --- a/crates/codex-win-engine/src/portable.rs +++ b/crates/codex-win-engine/src/portable.rs @@ -817,6 +817,13 @@ pub(crate) fn repair_portable_launch_entry(root: &Path) -> Result<(), EngineErro if let Err(error) = create_start_menu_shortcut(root) { log::warn!("portable shortcut repair failed: {error}"); } + #[cfg(windows)] + if let Some(appdata) = std::env::var_os("APPDATA") { + let programs = PathBuf::from(appdata).join("Microsoft/Windows/Start Menu/Programs"); + if let Err(error) = repair_legacy_start_menu_icon(root, &programs) { + log::warn!("legacy portable shortcut icon repair failed: {error}"); + } + } // Legacy uninstall commands must learn protocol cleanup before its handler // is added. Refreshing them is also required when repairing a flat install. let version = read_codex_app_version_from_install_root(root).or_else(|| { @@ -993,6 +1000,57 @@ pub fn create_manager_launch_shortcut( Ok(()) } +/// Repair only a legacy link to this installation with a blank or obsolete +/// default icon. Preserve custom icon sources, including unresolved paths. +#[cfg(windows)] +fn repair_legacy_start_menu_icon(install_root: &Path, programs: &Path) -> Result { + let shortcut = programs.join("ChatGPT.lnk"); + if !shortcut.exists() { + return Ok(false); + } + let Some(exe) = installed_app_exe(install_root) else { + return Ok(false); + }; + let nested = + exe.parent() == Some(install_root.join(crate::portable_command::APP_DIR_NAME).as_path()); + let Some(app_dir) = exe.parent() else { + return Ok(false); + }; + let launcher_paths = [ + install_root.join(crate::portable_command::LAUNCHER_NAME), + install_root.join("Codex.exe"), + install_root.join("ChatGPT.exe"), + ]; + let launchers = if nested { + &launcher_paths[..] + } else { + &launcher_paths[..1] + }; + let official_icon_paths = [ + app_dir.join("resources/icon-chatgpt.ico"), + app_dir.join("ChatGPT.exe"), + app_dir.join("Codex.exe"), + ]; + crate::portable_shortcut::ensure_icon_if(&shortcut, &shortcut_icon(install_root), |current| { + let is_entry = crate::same_windows_path(¤t.target, &exe) + || launchers + .iter() + .any(|path| crate::same_windows_path(¤t.target, path)); + if !is_entry { + return false; + } + current.icon.as_os_str().is_empty() + || launchers + .iter() + .any(|path| crate::same_windows_path(¤t.icon, path)) + || (official_icon_paths + .iter() + .any(|path| crate::same_windows_path(¤t.icon, path)) + && !current.icon.is_file()) + }) + .map_err(|error| io_err("repair legacy portable shortcut icon", error)) +} + #[cfg(all(windows, not(test)))] fn create_start_menu_shortcut(install_root: &Path) -> Result { let Some(exe) = installed_app_exe(install_root) else { @@ -1769,6 +1827,220 @@ mod tests { assert_eq!(pick_shortcut_icon(None, &launcher, |_| false), launcher); } + #[cfg(windows)] + #[test] + fn legacy_shortcut_icon_repairs_only_this_installs_entry_points() { + let parent = temp_test_dir("legacy-shortcut-icon"); + let root = parent.join("便携 O'Neil & 子目录"); + let app = root.join("app"); + fs::create_dir_all(app.join("resources")).unwrap(); + let exe = app.join("ChatGPT.exe"); + fs::write(&exe, b"payload fixture").unwrap(); + let icon = app.join("resources/icon-chatgpt.ico"); + fs::write(&icon, b"icon fixture").unwrap(); + let shortcut = parent.join("ChatGPT.lnk"); + assert!(!repair_legacy_start_menu_icon(&root, &parent).unwrap()); + assert!(!shortcut.exists()); + + for target in [ + root.join("ChatGPT.exe"), + root.join("Codex.exe"), + root.join(crate::portable_command::LAUNCHER_NAME), + exe.clone(), + ] { + if !target.exists() { + fs::write(&target, b"launcher fixture").unwrap(); + } + crate::portable_shortcut::create_with_arguments( + &shortcut, + &target, + &app, + &root.join("ChatGPT.exe"), + "--user-flag codex://threads/example", + ) + .unwrap(); + let before = crate::portable_shortcut::read_spec(&shortcut).unwrap(); + assert!(repair_legacy_start_menu_icon(&root, &parent).unwrap()); + let mut expected = before; + expected.icon = icon.clone(); + expected.icon_index = 0; + assert_eq!( + crate::portable_shortcut::read_spec(&shortcut).unwrap(), + expected + ); + let saved = fs::read(&shortcut).unwrap(); + let modified = fs::metadata(&shortcut).unwrap().modified().unwrap(); + assert!(!repair_legacy_start_menu_icon(&root, &parent).unwrap()); + assert_eq!(fs::read(&shortcut).unwrap(), saved); + assert_eq!( + fs::metadata(&shortcut).unwrap().modified().unwrap(), + modified + ); + } + + for target in [ + parent.join("ChatGPT.exe"), + parent.join("other-install/app/ChatGPT.exe"), + root.join("tools/helper.exe"), + app.join("resources/codex.exe"), + ] { + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(&target, b"unrelated fixture").unwrap(); + // Use a repairable icon so this checks target ownership rather + // than passing because the custom-icon policy skips the link. + crate::portable_shortcut::create( + &shortcut, + &target, + &app, + &root.join("ChatGPT.exe"), + ) + .unwrap(); + let saved = fs::read(&shortcut).unwrap(); + assert!(!repair_legacy_start_menu_icon(&root, &parent).unwrap()); + assert_eq!(fs::read(&shortcut).unwrap(), saved); + } + fs::remove_dir_all(parent).unwrap(); + } + + #[cfg(windows)] + #[test] + fn legacy_shortcut_icon_supports_flat_installs_and_missing_payloads() { + let root = temp_test_dir("legacy-shortcut-flat"); + let exe = root.join("ChatGPT.exe"); + fs::write(&exe, b"legacy payload fixture").unwrap(); + let shortcut = root.join("ChatGPT.lnk"); + let stale = root.join("resources/icon-chatgpt.ico"); + crate::portable_shortcut::create(&shortcut, &exe, &root, &stale).unwrap(); + assert!(repair_legacy_start_menu_icon(&root, &root).unwrap()); + assert!(crate::same_windows_path( + &crate::portable_shortcut::read_spec(&shortcut).unwrap().icon, + &exe, + )); + crate::portable_shortcut::create(&shortcut, &exe, &root, &stale).unwrap(); + fs::remove_file(&exe).unwrap(); + let saved = fs::read(&shortcut).unwrap(); + assert!(!repair_legacy_start_menu_icon(&root, &root).unwrap()); + assert_eq!(fs::read(&shortcut).unwrap(), saved); + fs::remove_dir_all(root).unwrap(); + } + + #[cfg(windows)] + #[test] + fn legacy_shortcut_icon_repairs_blank_and_missing_default_sources() { + let parent = temp_test_dir("legacy-shortcut-default-icon"); + let root = parent.join("portable"); + let app = root.join("app"); + fs::create_dir_all(&app).unwrap(); + let exe = app.join("ChatGPT.exe"); + fs::write(&exe, b"payload fixture").unwrap(); + let shortcut = parent.join("ChatGPT.lnk"); + + for icon in [ + PathBuf::new(), + app.join("resources/icon-chatgpt.ico"), + app.join("Codex.exe"), + ] { + crate::portable_shortcut::create_with_arguments( + &shortcut, + &exe, + &app, + &icon, + "--user-flag", + ) + .unwrap(); + let mut expected = crate::portable_shortcut::read_spec(&shortcut).unwrap(); + assert!(repair_legacy_start_menu_icon(&root, &parent).unwrap()); + expected.icon = exe.clone(); + expected.icon_index = 0; + assert_eq!( + crate::portable_shortcut::read_spec(&shortcut).unwrap(), + expected + ); + let bytes = fs::read(&shortcut).unwrap(); + let modified = fs::metadata(&shortcut).unwrap().modified().unwrap(); + assert!(!repair_legacy_start_menu_icon(&root, &parent).unwrap()); + assert_eq!(fs::read(&shortcut).unwrap(), bytes); + assert_eq!( + fs::metadata(&shortcut).unwrap().modified().unwrap(), + modified + ); + } + fs::remove_dir_all(parent).unwrap(); + } + + #[cfg(windows)] + #[test] + fn legacy_shortcut_icon_preserves_explicit_custom_and_current_icons() { + let parent = temp_test_dir("legacy-shortcut-custom-icon"); + let root = parent.join("portable"); + let app = root.join("app"); + fs::create_dir_all(app.join("resources")).unwrap(); + let exe = app.join("ChatGPT.exe"); + fs::write(&exe, b"payload fixture").unwrap(); + let official_icon = app.join("resources/icon-chatgpt.ico"); + fs::write(&official_icon, b"official icon fixture").unwrap(); + let custom = root.join("custom.ico"); + fs::write(&custom, b"custom icon fixture").unwrap(); + let external = parent.join("custom.ico"); + fs::write(&external, b"external icon fixture").unwrap(); + let shortcut = parent.join("ChatGPT.lnk"); + + for icon in [ + custom, + external, + parent.join("missing-custom.ico"), + PathBuf::from("relative.ico"), + PathBuf::from(r"%USERPROFILE%\custom.ico"), + official_icon, + exe.clone(), + ] { + crate::portable_shortcut::create(&shortcut, &exe, &app, &icon).unwrap(); + let bytes = fs::read(&shortcut).unwrap(); + let modified = fs::metadata(&shortcut).unwrap().modified().unwrap(); + assert!( + !repair_legacy_start_menu_icon(&root, &parent).unwrap(), + "{icon:?}" + ); + assert_eq!(fs::read(&shortcut).unwrap(), bytes, "{icon:?}"); + assert_eq!( + fs::metadata(&shortcut).unwrap().modified().unwrap(), + modified + ); + } + + // In the flat layout ChatGPT.exe is the payload, not a launcher alias. + let flat = parent.join("flat"); + fs::create_dir_all(&flat).unwrap(); + let flat_exe = flat.join("ChatGPT.exe"); + fs::write(&flat_exe, b"legacy payload fixture").unwrap(); + crate::portable_shortcut::create(&shortcut, &flat_exe, &flat, &flat_exe).unwrap(); + let bytes = fs::read(&shortcut).unwrap(); + assert!(!repair_legacy_start_menu_icon(&flat, &parent).unwrap()); + assert_eq!(fs::read(&shortcut).unwrap(), bytes); + fs::remove_dir_all(parent).unwrap(); + } + + #[cfg(windows)] + #[test] + fn legacy_shortcut_icon_does_not_replace_malformed_or_other_named_links() { + let parent = temp_test_dir("legacy-shortcut-invalid"); + let root = parent.join("portable"); + let app = root.join("app"); + fs::create_dir_all(&app).unwrap(); + fs::write(app.join("ChatGPT.exe"), b"payload fixture").unwrap(); + let untouched = b"not a Shell Link"; + let other = parent.join("Codex.lnk"); + fs::write(&other, untouched).unwrap(); + assert!(!repair_legacy_start_menu_icon(&root, &parent).unwrap()); + assert_eq!(fs::read(&other).unwrap(), untouched); + let shortcut = parent.join("ChatGPT.lnk"); + assert!(!shortcut.exists()); + fs::write(&shortcut, untouched).unwrap(); + assert!(repair_legacy_start_menu_icon(&root, &parent).is_err()); + assert_eq!(fs::read(&shortcut).unwrap(), untouched); + fs::remove_dir_all(parent).unwrap(); + } + #[cfg(windows)] #[test] fn portable_shortcut_identity_preserves_its_launcher_target() { diff --git a/crates/codex-win-engine/src/portable_shortcut.rs b/crates/codex-win-engine/src/portable_shortcut.rs index b7d41b0..46a34eb 100644 --- a/crates/codex-win-engine/src/portable_shortcut.rs +++ b/crates/codex-win-engine/src/portable_shortcut.rs @@ -86,15 +86,22 @@ fn buffer_to_path(buffer: &[u16]) -> PathBuf { } pub(crate) fn read_spec(shortcut: &Path) -> windows::core::Result { - use windows::{core::BSTR, Win32::System::Com::STGM_READ}; + use windows::Win32::System::Com::STGM_READ; let _apartment = Apartment::initialize()?; let shortcut = wide(shortcut); - // SAFETY: load the saved link into a new object and read into initialized, - // sized UTF-16 buffers. Interfaces drop before the COM apartment. + // SAFETY: the terminated path outlives Load. Interfaces drop before COM. unsafe { let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?; let persist: IPersistFile = link.cast()?; persist.Load(PCWSTR(shortcut.as_ptr()), STGM_READ)?; + read_loaded_spec(&link) + } +} + +fn read_loaded_spec(link: &IShellLinkW) -> windows::core::Result { + use windows::core::BSTR; + // SAFETY: read into initialized, sized UTF-16 buffers while the link is alive. + unsafe { let mut target = vec![0u16; 32768]; let mut workdir = vec![0u16; 32768]; let mut icon = vec![0u16; 32768]; @@ -165,13 +172,39 @@ pub(crate) fn ensure(shortcut: &Path, desired: &ShortcutSpec) -> windows::core:: Ok(true) } -/// Point an existing link at `icon` while keeping its target, working -/// directory and the user's arguments. Returns whether anything was written. +/// Point an existing link at `icon` while preserving its other Shell metadata. +/// Returns whether anything was written; an identical icon leaves the file alone. pub(crate) fn ensure_icon(shortcut: &Path, icon: &Path) -> windows::core::Result { - let mut spec = read_spec(shortcut)?; - spec.icon = icon.to_path_buf(); - spec.icon_index = 0; - ensure(shortcut, &spec) + ensure_icon_if(shortcut, icon, |_| true) +} + +/// Evaluate the repair policy on the same loaded link that will be saved. +pub(crate) fn ensure_icon_if( + shortcut: &Path, + icon: &Path, + should_repair: impl FnOnce(&ShortcutSpec) -> bool, +) -> windows::core::Result { + use windows::Win32::System::Com::STGM_READ; + let _apartment = Apartment::initialize()?; + let shortcut = wide(shortcut); + // SAFETY: load the existing link, read into an initialized UTF-16 buffer, + // and change only its icon on the same object. Interfaces drop before COM. + unsafe { + let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?; + let persist: IPersistFile = link.cast()?; + persist.Load(PCWSTR(shortcut.as_ptr()), STGM_READ)?; + let current = read_loaded_spec(&link)?; + if current.icon_index == 0 && crate::same_windows_path(¤t.icon, icon) { + return Ok(false); + } + if !should_repair(¤t) { + return Ok(false); + } + let icon = wide(icon); + link.SetIconLocation(PCWSTR(icon.as_ptr()), 0)?; + persist.Save(PCWSTR(shortcut.as_ptr()), true)?; + } + Ok(true) } #[cfg(test)] @@ -292,6 +325,147 @@ mod launch_shortcut_tests { std::fs::remove_dir_all(root).unwrap(); } + #[test] + fn ensure_icon_repairs_the_index_without_changing_other_fields() { + let root = temp_root("ensure-icon-index"); + let exe = std::env::current_exe().unwrap(); + let shortcut = root.join("Codex.lnk"); + let mut spec = ShortcutSpec::new(&exe, &root, &exe, "--user-flag"); + spec.icon_index = 7; + write_spec(&shortcut, &spec).unwrap(); + assert!(ensure_icon(&shortcut, &exe).unwrap()); + spec.icon_index = 0; + assert!(read_spec(&shortcut).unwrap().matches(&spec)); + assert!(!ensure_icon(&shortcut, &exe).unwrap()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn ensure_icon_does_not_create_missing_or_replace_invalid_links() { + let root = temp_root("ensure-icon-invalid"); + let exe = std::env::current_exe().unwrap(); + let shortcut = root.join("Codex.lnk"); + assert!(ensure_icon(&shortcut, &exe).is_err()); + assert!(!shortcut.exists()); + let invalid = b"not a Shell Link"; + std::fs::write(&shortcut, invalid).unwrap(); + assert!(ensure_icon(&shortcut, &exe).is_err()); + assert_eq!(std::fs::read(&shortcut).unwrap(), invalid); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn ensure_icon_does_not_add_an_app_id_to_unidentified_links() { + use windows::Win32::System::{Com::STGM_READ, Variant::VT_EMPTY}; + + let root = temp_root("ensure-icon-no-app-id"); + let exe = std::env::current_exe().unwrap(); + let shortcut = root.join("Codex.lnk"); + let shortcut_wide = wide(&shortcut); + let exe_wide = wide(&exe); + { + let _apartment = Apartment::initialize().unwrap(); + // SAFETY: save a link without an AppUserModelID. Terminated buffers + // outlive these calls and interfaces drop before the COM apartment. + unsafe { + let link: IShellLinkW = + CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).unwrap(); + link.SetPath(PCWSTR(exe_wide.as_ptr())).unwrap(); + let persist: IPersistFile = link.cast().unwrap(); + persist.Save(PCWSTR(shortcut_wide.as_ptr()), true).unwrap(); + } + } + assert!(ensure_icon(&shortcut, &exe).unwrap()); + { + let _apartment = Apartment::initialize().unwrap(); + // SAFETY: load the saved link and inspect the variant's type tag; + // no union payload is interpreted. Interfaces drop before COM. + unsafe { + let link: IShellLinkW = + CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).unwrap(); + let persist: IPersistFile = link.cast().unwrap(); + persist + .Load(PCWSTR(shortcut_wide.as_ptr()), STGM_READ) + .unwrap(); + let store: IPropertyStore = link.cast().unwrap(); + let value = store.GetValue(&APP_ID_KEY).unwrap(); + assert_eq!(value.Anonymous.Anonymous.vt, VT_EMPTY); + } + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn ensure_icon_preserves_existing_shell_metadata() { + use windows::Win32::System::Com::STGM_READ; + + let root = temp_root("ensure-icon-metadata"); + let workdir = root.join("便携 O'Neil & 子目录"); + std::fs::create_dir_all(&workdir).unwrap(); + let exe = std::env::current_exe().unwrap(); + let target = workdir.join("Missing.exe"); + let shortcut = workdir.join("Codex.lnk"); + let mut original = ShortcutSpec::new( + &target, + &workdir, + &root.join("stale.ico"), + "--user-flag \"参数 with spaces\" codex://threads/example", + ); + original.app_id = "custom.codex.identity".into(); + write_spec(&shortcut, &original).unwrap(); + let description: Vec = "My Codex shortcut 快捷方式" + .encode_utf16() + .chain([0]) + .collect(); + let shortcut_wide = wide(&shortcut); + { + let _apartment = Apartment::initialize().unwrap(); + // SAFETY: the link and description buffers are terminated and live + // through the COM calls; interfaces drop before the apartment. + unsafe { + let link: IShellLinkW = + CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).unwrap(); + let persist: IPersistFile = link.cast().unwrap(); + persist + .Load(PCWSTR(shortcut_wide.as_ptr()), STGM_READ) + .unwrap(); + link.SetDescription(PCWSTR(description.as_ptr())).unwrap(); + link.SetHotkey(0x0643).unwrap(); + persist.Save(PCWSTR(shortcut_wide.as_ptr()), true).unwrap(); + } + } + + assert!(ensure_icon(&shortcut, &exe).unwrap()); + let mut expected = original; + expected.icon = exe.clone(); + assert!(read_spec(&shortcut).unwrap().matches(&expected)); + { + let _apartment = Apartment::initialize().unwrap(); + // SAFETY: load the existing link and read into an initialized buffer. + unsafe { + let link: IShellLinkW = + CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).unwrap(); + let persist: IPersistFile = link.cast().unwrap(); + persist + .Load(PCWSTR(shortcut_wide.as_ptr()), STGM_READ) + .unwrap(); + let mut saved_description = vec![0u16; description.len()]; + link.GetDescription(&mut saved_description).unwrap(); + assert_eq!(saved_description, description); + assert_eq!(link.GetHotkey().unwrap(), 0x0643); + } + } + let first = std::fs::read(&shortcut).unwrap(); + let modified = std::fs::metadata(&shortcut).unwrap().modified().unwrap(); + assert!(!ensure_icon(&shortcut, &exe).unwrap()); + assert_eq!(std::fs::read(&shortcut).unwrap(), first); + assert_eq!( + std::fs::metadata(&shortcut).unwrap().modified().unwrap(), + modified + ); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn ensure_icon_only_repairs_icon_and_keeps_user_arguments() { let root = temp_root("ensure-icon"); From 0aa5300ad40b2ca4f7364923a6e1820f9552a1f8 Mon Sep 17 00:00:00 2001 From: buaabw <2725238326@qq.com> Date: Thu, 22 Oct 2026 08:57:24 +0800 Subject: [PATCH 2/5] fix(deps): remove vulnerable archive and TLS dependencies --- Cargo.lock | 74 +++++++++------------- Cargo.toml | 2 +- crates/codex-theme-engine/src/import.rs | 33 +++++++++- crates/codex-win-engine/src/app_version.rs | 5 +- crates/codex-win-engine/src/portable.rs | 36 ++++++++++- src-tauri/Cargo.toml | 5 +- src-tauri/src/app/mac_update.rs | 18 ++++-- 7 files changed, 117 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7cabd69..2ca1575 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,15 +64,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -512,6 +503,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.2" @@ -566,7 +563,7 @@ dependencies = [ "webview2-com", "windows-core", "windows-sys 0.61.2", - "zip 8.6.0", + "zip", ] [[package]] @@ -600,7 +597,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "url", - "zip 8.6.0", + "zip", ] [[package]] @@ -617,7 +614,7 @@ dependencies = [ "uuid", "windows", "windows-sys 0.61.2", - "zip 8.6.0", + "zip", ] [[package]] @@ -882,17 +879,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -2414,6 +2400,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3238,9 +3236,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "once_cell", "ring", @@ -3300,9 +3298,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -4224,7 +4222,6 @@ dependencies = [ "tokio", "url", "windows-sys 0.61.2", - "zip 4.6.1", ] [[package]] @@ -5025,9 +5022,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] @@ -5744,26 +5741,15 @@ dependencies = [ [[package]] name = "zip" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" -dependencies = [ - "arbitrary", - "crc32fast", - "indexmap 2.14.0", - "memchr", -] - -[[package]] -name = "zip" -version = "8.6.0" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ "crc32fast", "flate2", "indexmap 2.14.0", "memchr", + "nix", "typed-path", "zopfli", ] diff --git a/Cargo.toml b/Cargo.toml index ce60bba..9a881f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,4 +35,4 @@ tokio = "1" url = "2.5" uuid = { version = "1", features = ["v4"] } windows-sys = "0.61.2" -zip = { version = "8", default-features = false, features = ["deflate"] } +zip = { version = "9.0.5", default-features = false, features = ["deflate"] } diff --git a/crates/codex-theme-engine/src/import.rs b/crates/codex-theme-engine/src/import.rs index 41bc060..77591a6 100644 --- a/crates/codex-theme-engine/src/import.rs +++ b/crates/codex-theme-engine/src/import.rs @@ -80,11 +80,14 @@ fn extract_and_place( let entry = archive .by_index(index) .map_err(|e| err(format!("读取压缩包条目失败: {e}")))?; + let entry_name = entry + .name() + .map_err(|e| err(format!("读取压缩包条目名称失败: {e}")))?; let Some(relative) = entry .enclosed_name() .and_then(|p| safe_entry_path(p.to_string_lossy().as_ref())) else { - return Err(err(format!("主题包含非法路径条目: {}", entry.name()))); + return Err(err(format!("主题包含非法路径条目: {entry_name}"))); }; if entry.is_dir() { std::fs::create_dir_all(staging.join(&relative)) @@ -221,6 +224,34 @@ mod tests { assert!(error.contains("theme.json"), "{error}"); } + #[test] + fn imports_legacy_cp437_entry_names() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("themes"); + let skin = tmp.path().join("cp437-name.codexskin"); + let mut entries = minimal_entries("cp437-name"); + entries.push(("cafe.bin".to_string(), b"legacy filename".to_vec())); + let refs: Vec<(&str, &[u8])> = + entries.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect(); + write_zip(&skin, &refs); + let mut bytes = std::fs::read(&skin).unwrap(); + let name = b"cafe.bin"; + let mut replaced = 0; + for index in 0..=bytes.len() - name.len() { + if bytes[index..index + name.len()] == *name { + bytes[index + 3] = 0x82; // CP437 encodes é as one byte. + replaced += 1; + } + } + assert_eq!(replaced, 2, "update local and central directory names"); + std::fs::write(&skin, bytes).unwrap(); + assert_eq!(import_codexskin(&skin, &root).unwrap().id, "cp437-name"); + assert_eq!( + std::fs::read(root.join("cp437-name/café.bin")).unwrap(), + b"legacy filename" + ); + } + #[test] fn rejects_invalid_package_content() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/codex-win-engine/src/app_version.rs b/crates/codex-win-engine/src/app_version.rs index e35603e..dd123fb 100644 --- a/crates/codex-win-engine/src/app_version.rs +++ b/crates/codex-win-engine/src/app_version.rs @@ -129,7 +129,10 @@ pub fn read_codex_app_version_from_msix(path: &Path) -> Option { let Ok(mut entry) = archive.by_index(index) else { continue; }; - let normalized = entry.name().replace('\\', "/").to_ascii_lowercase(); + let normalized = match entry.name() { + Ok(name) => name.replace('\\', "/").to_ascii_lowercase(), + Err(_) => continue, + }; if normalized != "resources/app.asar" && !normalized.ends_with("/resources/app.asar") { continue; } diff --git a/crates/codex-win-engine/src/portable.rs b/crates/codex-win-engine/src/portable.rs index dcc4f97..0b4b1cb 100644 --- a/crates/codex-win-engine/src/portable.rs +++ b/crates/codex-win-engine/src/portable.rs @@ -425,7 +425,10 @@ fn extract_msix(msix_path: &Path, dest: &Path) -> Result { let mut file = zip .by_index(idx) .map_err(|e| EngineError::Msix(format!("read zip entry {idx}: {e}")))?; - let zip_name = file.name().to_string(); + let zip_name = file + .name() + .map_err(|e| EngineError::Msix(format!("decode zip entry {idx} name: {e}")))? + .into_owned(); let (decoded_components, was_percent_encoded) = decode_zip_entry_path(&zip_name)?; if file.is_dir() { continue; @@ -2821,6 +2824,37 @@ mod tests { zip.finish().unwrap(); } + #[test] + fn extracts_legacy_cp437_zip_entry_names() { + let root = temp_test_dir("cp437-zip-entry-name"); + let msix = root.join("codex.msix"); + let extracted = root.join("extracted"); + fs::create_dir_all(&extracted).unwrap(); + let payload = b"legacy filename"; + write_block_mapped_msix( + &msix, + &[("café.bin", payload.len() as u64)], + &[("cafe.bin", payload)], + ); + let mut bytes = fs::read(&msix).unwrap(); + let name = b"cafe.bin"; + let mut replaced = 0; + for index in 0..=bytes.len() - name.len() { + if bytes[index..index + name.len()] == *name { + bytes[index + 3] = 0x82; // CP437 encodes é as one byte. + replaced += 1; + } + } + assert_eq!(replaced, 2, "update local and central directory names"); + fs::write(&msix, bytes).unwrap(); + extract_msix(&msix, &extracted).unwrap(); + assert_eq!( + fs::read(extracted.join("café.bin")).unwrap(), + b"legacy filename" + ); + fs::remove_dir_all(root).unwrap(); + } + #[test] fn extracts_percent_encoded_payload_to_block_map_logical_paths() { let root = temp_test_dir("block-map-logical-paths"); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a03dee8..3b336ea 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -37,7 +37,10 @@ tauri-plugin-autostart = "2" tauri-plugin-dialog = "2.7.1" tauri-plugin-log = "2" tauri-plugin-single-instance = "2" -tauri-plugin-updater = "2" +# Releases use raw signed NSIS executables on Windows. Keep TLS and proxy +# support without the legacy ZIP installer parser; macOS tar.gz support is +# unconditional in the updater's macOS dependencies. +tauri-plugin-updater = { version = "2", default-features = false, features = ["rustls-tls", "system-proxy"] } thiserror.workspace = true tokio = { workspace = true, features = ["sync", "time"] } url.workspace = true diff --git a/src-tauri/src/app/mac_update.rs b/src-tauri/src/app/mac_update.rs index 0a262b0..eb22b28 100644 --- a/src-tauri/src/app/mac_update.rs +++ b/src-tauri/src/app/mac_update.rs @@ -1007,10 +1007,14 @@ where let mut entry = archive .by_index(index) .map_err(|e| AppError::Engine(format!("读取 ZIP 条目失败: {e}")))?; + let entry_name = entry + .name() + .map_err(|e| AppError::Engine(format!("读取 ZIP 条目名称失败: {e}")))? + .into_owned(); let Some(enclosed) = entry.enclosed_name() else { return Err(AppError::Engine(format!( "ZIP 包含不安全路径:{}", - entry.name() + entry_name ))); }; let declared_size = entry.size(); @@ -1020,7 +1024,7 @@ where if declared_size > remaining { return Err(AppError::Engine(format!( "ZIP 声明解压后大小超出允许范围:{}", - entry.name() + entry_name ))); } @@ -1028,7 +1032,7 @@ where if declared_size == 0 || declared_size > MAX_ZIP_SYMLINK_TARGET_BYTES { return Err(AppError::Engine(format!( "ZIP 包含无效符号链接:{}", - entry.name() + entry_name ))); } let mut target = Vec::new(); @@ -1040,11 +1044,11 @@ where if actual == 0 || actual > MAX_ZIP_SYMLINK_TARGET_BYTES { return Err(AppError::Engine(format!( "ZIP 符号链接实际展开大小超出允许范围:{}", - entry.name() + entry_name ))); } let target = String::from_utf8(target).map_err(|_| { - AppError::Engine(format!("ZIP 符号链接目标不是 UTF-8:{}", entry.name())) + AppError::Engine(format!("ZIP 符号链接目标不是 UTF-8:{}", entry_name)) })?; validate_zip_symlink_target(&enclosed, &target)?; actual @@ -1058,13 +1062,13 @@ where if actual_size > remaining { return Err(AppError::Engine(format!( "ZIP 实际解压后大小超出允许范围:{}", - entry.name() + entry_name ))); } if actual_size != declared_size { return Err(AppError::Engine(format!( "ZIP 条目声明大小与实际展开大小不一致:{}({} != {}),拒绝安装", - entry.name(), + entry_name, declared_size, actual_size ))); From d254e465da449a019bdcb037bc8bc73c676872fa Mon Sep 17 00:00:00 2001 From: buaabw <2725238326@qq.com> Date: Thu, 22 Oct 2026 08:57:25 +0800 Subject: [PATCH 3/5] test(windows): verify HTTPS-only download policy --- crates/codex-win-engine/src/download.rs | 37 ++++++++++--------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/crates/codex-win-engine/src/download.rs b/crates/codex-win-engine/src/download.rs index e4b2671..9172f85 100644 --- a/crates/codex-win-engine/src/download.rs +++ b/crates/codex-win-engine/src/download.rs @@ -609,7 +609,7 @@ mod tests { } #[test] - fn download_with_progress_reports_final_size() { + fn download_rejects_file_urls() { if hidden_command(curl_exe()) .arg("--version") .output() @@ -618,29 +618,22 @@ mod tests { return; } - let root = - std::env::temp_dir().join(format!("codex-win-engine-progress-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); + let root = std::env::temp_dir().join(format!( + "codex-win-engine-file-url-{}", + uuid::Uuid::new_v4() + )); std::fs::create_dir_all(&root).unwrap(); let source = root.join("source.bin"); let dest = root.join("dest.bin"); - let bytes = vec![0x4d; 1024 * 1024]; - std::fs::write(&source, &bytes).unwrap(); - - let seen = std::sync::Mutex::new(Vec::new()); - let result = download_to_with_progress(&file_url(&source), &dest, &|downloaded| { - seen.lock().unwrap().push(downloaded); - }); - if let Err(EngineError::Io(message)) = &result { - if message.contains("Protocol \"file\" disabled") { - let _ = std::fs::remove_dir_all(&root); - return; - } - } - result.unwrap(); - - assert_eq!(std::fs::read(&dest).unwrap(), bytes); - assert!(seen.lock().unwrap().contains(&(1024 * 1024))); - let _ = std::fs::remove_dir_all(&root); + std::fs::write(&source, b"local file must not be downloaded").unwrap(); + + // Production downloads and redirects are HTTPS-only. A file:// fixture + // cannot exercise successful transfer progress through this path. + let error = download_to_with_progress(&file_url(&source), &dest, &|_| {}) + .unwrap_err(); + let message = error.to_string().to_ascii_lowercase(); + assert!(message.contains("protocol") && message.contains("file"), "{error}"); + assert!(!dest.exists(), "a rejected URL must not create the destination"); + std::fs::remove_dir_all(root).unwrap(); } } From 5d38ec577609b7635f30f2f9203db5acd6710310 Mon Sep 17 00:00:00 2001 From: buaabw <2725238326@qq.com> Date: Thu, 22 Oct 2026 21:54:15 +0800 Subject: [PATCH 4/5] test(windows): retain transfer progress coverage --- crates/codex-win-engine/src/download.rs | 77 ++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 7 deletions(-) diff --git a/crates/codex-win-engine/src/download.rs b/crates/codex-win-engine/src/download.rs index 9172f85..4e0e2b2 100644 --- a/crates/codex-win-engine/src/download.rs +++ b/crates/codex-win-engine/src/download.rs @@ -527,6 +527,20 @@ pub fn sha256_file(path: &Path) -> Result { mod tests { use super::*; + static DOWNLOAD_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn curl_available() -> bool { + let available = hidden_command(curl_exe()) + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()); + assert!( + available || !cfg!(windows), + "Windows download tests require curl" + ); + available + } + fn file_url(path: &Path) -> String { let mut path = path.to_string_lossy().replace('\\', "/"); if !path.starts_with('/') { @@ -537,6 +551,7 @@ mod tests { #[test] fn download_guard_rejects_concurrent_downloads() { + let _lock = DOWNLOAD_TEST_LOCK.lock().unwrap(); let guard = DownloadGuard::acquire().unwrap(); assert!(cancel_active_download()); assert!(DownloadGuard::acquire().is_err()); @@ -610,11 +625,8 @@ mod tests { #[test] fn download_rejects_file_urls() { - if hidden_command(curl_exe()) - .arg("--version") - .output() - .is_err() - { + let _lock = DOWNLOAD_TEST_LOCK.lock().unwrap(); + if !curl_available() { return; } @@ -632,8 +644,59 @@ mod tests { let error = download_to_with_progress(&file_url(&source), &dest, &|_| {}) .unwrap_err(); let message = error.to_string().to_ascii_lowercase(); - assert!(message.contains("protocol") && message.contains("file"), "{error}"); - assert!(!dest.exists(), "a rejected URL must not create the destination"); + assert!( + message.contains("protocol") && message.contains("file"), + "{error}" + ); + assert!( + !dest.exists(), + "a rejected URL must not create the destination" + ); + assert!( + !partial_path(&dest).exists(), + "a rejected URL must not write a partial file" + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn curl_attempt_reports_final_size() { + let _lock = DOWNLOAD_TEST_LOCK.lock().unwrap(); + if !curl_available() { + return; + } + let root = std::env::temp_dir().join(format!( + "codex-win-engine-progress-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let source = root.join("source.bin"); + let dest = root.join("dest.bin"); + let bytes = vec![0x4d; 1024 * 1024]; + std::fs::write(&source, &bytes).unwrap(); + let url = file_url(&source); + let seen = std::sync::Mutex::new(Vec::new()); + + // Exercise the transfer runner without changing the public download + // policy. This test permits only local files and ignores curl config. + let args = vec![ + "--disable".into(), + "-f".into(), + "-sS".into(), + "--proto".into(), + "=file".into(), + "--noproxy".into(), + "*".into(), + "-o".into(), + dest.to_string_lossy().into_owned(), + url, + ]; + run_curl_once("local fixture", &dest.to_string_lossy(), args, &|downloaded| { + seen.lock().unwrap().push(downloaded); + }) + .unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), bytes); + assert_eq!(seen.lock().unwrap().last(), Some(&(bytes.len() as u64))); std::fs::remove_dir_all(root).unwrap(); } } From 1252b9cecdb53f5705774182ab8224aa0f16fd5b Mon Sep 17 00:00:00 2001 From: buaabw <2725238326@qq.com> Date: Fri, 23 Oct 2026 12:02:24 +0800 Subject: [PATCH 5/5] fix(archives): preserve ZIP language flag filename decoding --- Cargo.lock | 10 ++ Cargo.toml | 1 + crates/codex-archive/Cargo.toml | 9 ++ crates/codex-archive/src/lib.rs | 139 +++++++++++++++++++++ crates/codex-theme-engine/Cargo.toml | 1 + crates/codex-theme-engine/src/import.rs | 99 ++++++++++++++- crates/codex-theme-engine/src/lib.rs | 4 + crates/codex-win-engine/Cargo.toml | 1 + crates/codex-win-engine/src/app_version.rs | 2 +- crates/codex-win-engine/src/lib.rs | 4 + crates/codex-win-engine/src/portable.rs | 100 ++++++++++++++- crates/test-support/zip.rs | 99 +++++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/src/app/mac_update.rs | 78 +++++++++++- src-tauri/src/lib.rs | 4 + 15 files changed, 541 insertions(+), 11 deletions(-) create mode 100644 crates/codex-archive/Cargo.toml create mode 100644 crates/codex-archive/src/lib.rs create mode 100644 crates/test-support/zip.rs diff --git a/Cargo.lock b/Cargo.lock index 2ca1575..a8eb9bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -537,6 +537,7 @@ name = "codex-app-manager" version = "0.5.16" dependencies = [ "base64 0.23.1", + "codex-archive", "codex-mac-engine", "codex-theme-engine", "codex-win-engine", @@ -566,6 +567,13 @@ dependencies = [ "zip", ] +[[package]] +name = "codex-archive" +version = "0.1.0" +dependencies = [ + "zip", +] + [[package]] name = "codex-mac-engine" version = "0.1.0" @@ -586,6 +594,7 @@ name = "codex-theme-engine" version = "0.1.0" dependencies = [ "base64 0.23.1", + "codex-archive", "futures-util", "log", "serde", @@ -604,6 +613,7 @@ dependencies = [ name = "codex-win-engine" version = "0.1.0" dependencies = [ + "codex-archive", "libc", "log", "roxmltree", diff --git a/Cargo.toml b/Cargo.toml index 9a881f6..8098211 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "src-tauri", + "crates/codex-archive", "crates/codex-mac-engine", "crates/codex-theme-engine", "crates/codex-win-engine", diff --git a/crates/codex-archive/Cargo.toml b/crates/codex-archive/Cargo.toml new file mode 100644 index 0000000..da4878c --- /dev/null +++ b/crates/codex-archive/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "codex-archive" +version = "0.1.0" +edition.workspace = true +license.workspace = true +description = "ZIP filename decoding and relative paths shared by the package readers." + +[dependencies] +zip.workspace = true diff --git a/crates/codex-archive/src/lib.rs b/crates/codex-archive/src/lib.rs new file mode 100644 index 0000000..853d750 --- /dev/null +++ b/crates/codex-archive/src/lib.rs @@ -0,0 +1,139 @@ +//! ZIP entry names must honor the language flag, not UTF-8 autodetection. + +use std::{borrow::Cow, io::Read, path::PathBuf, str::Utf8Error}; + +// Unicode mappings for CP437 bytes 0x80..=0xff. ASCII bytes map unchanged. +const CP437_HIGH: [char; 128] = [ + 'Ç', 'ü', 'é', 'â', 'ä', 'à', 'å', 'ç', 'ê', 'ë', 'è', 'ï', 'î', 'ì', 'Ä', 'Å', 'É', 'æ', 'Æ', + 'ô', 'ö', 'ò', 'û', 'ù', 'ÿ', 'Ö', 'Ü', '¢', '£', '¥', '₧', 'ƒ', 'á', 'í', 'ó', 'ú', 'ñ', 'Ñ', + 'ª', 'º', '¿', '⌐', '¬', '½', '¼', '¡', '«', '»', '░', '▒', '▓', '│', '┤', '╡', '╢', '╖', '╕', + '╣', '║', '╗', '╝', '╜', '╛', '┐', '└', '┴', '┬', '├', '─', '┼', '╞', '╟', '╚', '╔', '╩', '╦', + '╠', '═', '╬', '╧', '╨', '╤', '╥', '╙', '╘', '╒', '╓', '╫', '╪', '┘', '┌', '█', '▄', '▌', '▐', + '▀', 'α', 'ß', 'Γ', 'π', 'Σ', 'σ', 'µ', 'τ', 'Φ', 'Θ', 'Ω', 'δ', '∞', 'φ', 'ε', '∩', '≡', '±', + '≥', '≤', '⌠', '⌡', '÷', '≈', '°', '∙', '·', '√', 'ⁿ', '²', '■', '\u{a0}', +]; + +/// Decode the effective name using bit 11 (UTF-8) or CP437. +/// +/// ZIP 9 applies valid UnicodePath extra fields, including their CRC and UTF-8 +/// checks, to name_raw() and sets bit 11 before exposing the entry. Using these +/// effective bytes and flags preserves that override without guessing encodings. +/// Malformed names explicitly marked UTF-8 are rejected, not decoded as CP437. +pub fn entry_name<'a, R: Read>( + entry: &'a zip::read::ZipFile<'_, R>, +) -> Result, Utf8Error> { + let raw = entry.name_raw(); + if entry.flags().as_u16() & (1 << 11) != 0 || raw.is_ascii() { + return std::str::from_utf8(raw).map(Cow::Borrowed); + } + Ok(Cow::Owned( + raw.iter() + .map(|&byte| { + if byte < 0x80 { + char::from(byte) + } else { + CP437_HIGH[usize::from(byte - 0x80)] + } + }) + .collect(), + )) +} + +/// Validate the decoded name as a relative path on both Windows and Unix. +/// Normalize separators and internal `.`/`..` without allowing root escape. +/// Callers must use this returned path for subsequent checks and extraction. +pub fn enclosed_path(name: &str) -> Option { + if name.starts_with(['/', '\\']) || name.contains(['\0', ':']) { + return None; + } + let mut path = PathBuf::new(); + for component in name.split(['/', '\\']) { + match component { + "" | "." => {} + ".." if !path.pop() => return None, + ".." => {} + part => path.push(part), + } + } + (!path.as_os_str().is_empty()).then_some(path) +} + +#[cfg(test)] +#[path = "../../test-support/zip.rs"] +mod zip_test; + +#[cfg(test)] +mod tests { + use super::*; + use crate::zip_test::{archive, unicode_path_extra, Entry}; + use std::io::Cursor; + + #[test] + fn language_flag_disambiguates_the_same_raw_name() { + for (flags, expected) in [(0, "├⌐.webp"), (1 << 11, "é.webp")] { + let mut entry = Entry::new(b"\xc3\xa9.webp", b"data"); + entry.flags = flags; + let mut zip = zip::ZipArchive::new(Cursor::new(archive(&[entry]))).unwrap(); + let file = zip.by_index(0).unwrap(); + assert_eq!(file.flags().as_u16(), flags); + assert_eq!(entry_name(&file).unwrap(), expected); + } + } + + #[test] + fn unicode_path_requires_valid_crc_and_utf8() { + let raw = b"\xc3\xa9.webp"; + for (unicode, valid_crc, expected) in [ + ("资源.webp".as_bytes(), true, "资源.webp"), + ("资源.webp".as_bytes(), false, "├⌐.webp"), + (b"\xff.webp".as_slice(), true, "├⌐.webp"), + ] { + let extra = unicode_path_extra(raw, unicode, valid_crc); + let mut entry = Entry::new(raw, b"data"); + entry.extra = &extra; + let mut zip = zip::ZipArchive::new(Cursor::new(archive(&[entry]))).unwrap(); + assert_eq!(entry_name(&zip.by_index(0).unwrap()).unwrap(), expected); + } + } + + #[test] + fn invalid_utf8_is_only_allowed_without_the_utf8_flag() { + for flags in [0, 1 << 11] { + let mut entry = Entry::new(b"caf\x82.bin", b"data"); + entry.flags = flags; + let mut zip = zip::ZipArchive::new(Cursor::new(archive(&[entry]))).unwrap(); + let file = zip.by_index(0).unwrap(); + if flags == 0 { + assert_eq!(entry_name(&file).unwrap(), "café.bin"); + } else { + assert!(entry_name(&file).is_err()); + } + } + } + + #[test] + fn decoded_paths_cannot_escape_on_either_platform() { + for name in [ + "", + ".", + "..", + "../evil", + "a/../../evil", + r"a\..\..\evil", + "/absolute", + r"\absolute", + r"C:\evil", + "C:evil", + r"\\server\share", + "a\0b", + ] { + assert!(enclosed_path(name).is_none(), "{name:?}"); + } + for name in ["assets/├⌐.webp", r"assets\├⌐.webp", "assets/x/../├⌐.webp"] { + assert_eq!( + enclosed_path(name).unwrap(), + PathBuf::from("assets").join("├⌐.webp") + ); + } + } +} diff --git a/crates/codex-theme-engine/Cargo.toml b/crates/codex-theme-engine/Cargo.toml index 8ec31ef..fd78fc5 100644 --- a/crates/codex-theme-engine/Cargo.toml +++ b/crates/codex-theme-engine/Cargo.toml @@ -7,6 +7,7 @@ description = "Asset-based UI theme engine for the Codex desktop app: loopback C [dependencies] base64.workspace = true +codex-archive = { path = "../codex-archive" } futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } log.workspace = true serde.workspace = true diff --git a/crates/codex-theme-engine/src/import.rs b/crates/codex-theme-engine/src/import.rs index 77591a6..5a7ca69 100644 --- a/crates/codex-theme-engine/src/import.rs +++ b/crates/codex-theme-engine/src/import.rs @@ -21,7 +21,7 @@ fn err(message: impl Into) -> ThemeEngineError { } /// A zip entry name is accepted only as a plain relative path — no roots, no -/// parent hops, no drive prefixes (`ZipFile::enclosed_name` plus belt and +/// parent hops, no drive prefixes (decoded archive path plus belt and /// braces for portability). fn safe_entry_path(name: &str) -> Option { let path = Path::new(name); @@ -80,11 +80,9 @@ fn extract_and_place( let entry = archive .by_index(index) .map_err(|e| err(format!("读取压缩包条目失败: {e}")))?; - let entry_name = entry - .name() + let entry_name = codex_archive::entry_name(&entry) .map_err(|e| err(format!("读取压缩包条目名称失败: {e}")))?; - let Some(relative) = entry - .enclosed_name() + let Some(relative) = codex_archive::enclosed_path(&entry_name) .and_then(|p| safe_entry_path(p.to_string_lossy().as_ref())) else { return Err(err(format!("主题包含非法路径条目: {entry_name}"))); @@ -252,6 +250,97 @@ mod tests { ); } + #[test] + fn imports_cp437_names_that_are_also_valid_utf8() { + use crate::zip_test::{archive, Entry}; + + let tmp = tempfile::tempdir().unwrap(); + let skin = tmp.path().join("ambiguous.codexskin"); + let root = tmp.path().join("themes"); + let manifest = r#"{"schemaVersion":2,"id":"cp437","name":"T", + "assets":{"intro":"assets/├⌐.webp"}}"#; + let bytes = archive(&[ + Entry::new(b"theme.json", manifest.as_bytes()), + Entry::new(b"theme.css", b"html.codex-theme-studio {}"), + Entry::new(b"assets/\xc3\xa9.webp", b"RIFF\x01\x02"), + ]); + std::fs::write(&skin, bytes).unwrap(); + import_codexskin(&skin, &root).unwrap(); + assert_eq!( + std::fs::read(root.join("cp437/assets/├⌐.webp")).unwrap(), + b"RIFF\x01\x02" + ); + assert!(!root.join("cp437/assets/é.webp").exists()); + } + + #[test] + fn imports_utf8_and_unicode_path_asset_names() { + use crate::zip_test::{archive, unicode_path_extra, Entry}; + + let raw = b"assets/\xc3\xa9.webp"; + let unicode = unicode_path_extra(raw, "assets/资源.webp".as_bytes(), true); + let bad_crc = unicode_path_extra(raw, "assets/资源.webp".as_bytes(), false); + for (flags, extra, expected) in [ + (1 << 11, &[][..], "assets/é.webp"), + (0, unicode.as_slice(), "assets/资源.webp"), + (0, bad_crc.as_slice(), "assets/├⌐.webp"), + ] { + let tmp = tempfile::tempdir().unwrap(); + let skin = tmp.path().join("names.codexskin"); + let root = tmp.path().join("themes"); + let manifest = format!( + r#"{{"schemaVersion":2,"id":"names","name":"T", + "assets":{{"intro":"{expected}"}}}}"# + ); + let mut asset = Entry::new(raw, b"RIFF\x01\x02"); + asset.flags = flags; + asset.extra = extra; + std::fs::write( + &skin, + archive(&[ + Entry::new(b"theme.json", manifest.as_bytes()), + Entry::new(b"theme.css", b"html.codex-theme-studio {}"), + asset, + ]), + ) + .unwrap(); + import_codexskin(&skin, &root).unwrap(); + assert_eq!( + std::fs::read(root.join("names").join(expected)).unwrap(), + b"RIFF\x01\x02" + ); + } + } + + #[test] + fn rejects_unsafe_unicode_path_before_writing_assets() { + use crate::zip_test::{archive, unicode_path_extra, Entry}; + + let raw = b"assets/safe.webp"; + for unsafe_name in [ + "../escape.webp", r"..\escape.webp", "/absolute", r"C:\escape", "nul\0path", + ] { + let tmp = tempfile::tempdir().unwrap(); + let skin = tmp.path().join("unsafe.codexskin"); + let root = tmp.path().join("themes"); + let extra = unicode_path_extra(raw, unsafe_name.as_bytes(), true); + let mut asset = Entry::new(raw, b"RIFF\x01\x02"); + asset.extra = &extra; + std::fs::write( + &skin, + archive(&[ + Entry::new(b"theme.json", br#"{"schemaVersion":2,"id":"unsafe","name":"T"}"#), + asset, + ]), + ) + .unwrap(); + let error = import_codexskin(&skin, &root).unwrap_err().to_string(); + assert!(error.contains("非法路径"), "{unsafe_name:?}: {error}"); + assert!(!root.join("unsafe").exists()); + assert!(!root.join("escape.webp").exists()); + } + } + #[test] fn rejects_invalid_package_content() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/codex-theme-engine/src/lib.rs b/crates/codex-theme-engine/src/lib.rs index f14c1ae..de7b5fb 100644 --- a/crates/codex-theme-engine/src/lib.rs +++ b/crates/codex-theme-engine/src/lib.rs @@ -34,6 +34,10 @@ pub mod payload; pub mod theme; pub mod transaction; +#[cfg(test)] +#[path = "../../test-support/zip.rs"] +mod zip_test; + /// Engine version stamped into every payload; the renderer's verify pass /// checks it, so an engine upgrade re-injects on the next daemon tick. pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/codex-win-engine/Cargo.toml b/crates/codex-win-engine/Cargo.toml index d970d5c..e3bd978 100644 --- a/crates/codex-win-engine/Cargo.toml +++ b/crates/codex-win-engine/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true description = "Pure-logic engine for planning and staging Codex Windows MSIX updates." [dependencies] +codex-archive = { path = "../codex-archive" } roxmltree.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/codex-win-engine/src/app_version.rs b/crates/codex-win-engine/src/app_version.rs index dd123fb..1611d9f 100644 --- a/crates/codex-win-engine/src/app_version.rs +++ b/crates/codex-win-engine/src/app_version.rs @@ -129,7 +129,7 @@ pub fn read_codex_app_version_from_msix(path: &Path) -> Option { let Ok(mut entry) = archive.by_index(index) else { continue; }; - let normalized = match entry.name() { + let normalized = match codex_archive::entry_name(&entry) { Ok(name) => name.replace('\\', "/").to_ascii_lowercase(), Err(_) => continue, }; diff --git a/crates/codex-win-engine/src/lib.rs b/crates/codex-win-engine/src/lib.rs index 4493899..4a5cca0 100644 --- a/crates/codex-win-engine/src/lib.rs +++ b/crates/codex-win-engine/src/lib.rs @@ -34,6 +34,10 @@ pub mod sys; pub mod version; mod windows_process; +#[cfg(test)] +#[path = "../../test-support/zip.rs"] +mod zip_test; + pub use app_version::{ read_codex_app_version_from_asar, read_codex_app_version_from_install_root, read_codex_app_version_from_msix, diff --git a/crates/codex-win-engine/src/portable.rs b/crates/codex-win-engine/src/portable.rs index 0b4b1cb..618e8b1 100644 --- a/crates/codex-win-engine/src/portable.rs +++ b/crates/codex-win-engine/src/portable.rs @@ -425,8 +425,7 @@ fn extract_msix(msix_path: &Path, dest: &Path) -> Result { let mut file = zip .by_index(idx) .map_err(|e| EngineError::Msix(format!("read zip entry {idx}: {e}")))?; - let zip_name = file - .name() + let zip_name = codex_archive::entry_name(&file) .map_err(|e| EngineError::Msix(format!("decode zip entry {idx} name: {e}")))? .into_owned(); let (decoded_components, was_percent_encoded) = decode_zip_entry_path(&zip_name)?; @@ -2855,6 +2854,103 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn extracts_cp437_names_that_are_also_valid_utf8() { + use crate::zip_test::{archive, Entry}; + + let root = temp_test_dir("ambiguous-cp437"); + let msix = root.join("codex.msix"); + let extracted = root.join("extracted"); + fs::create_dir_all(&extracted).unwrap(); + let block_map = r#" + + "#; + let bytes = archive(&[ + Entry::new(b"AppxBlockMap.xml", block_map.as_bytes()), + Entry::new(b"AppxManifest.xml", b""), + Entry::new(b"app/\xc3\xa9.bin", b"payload"), + ]); + fs::write(&msix, bytes).unwrap(); + extract_msix(&msix, &extracted).unwrap(); + assert_eq!(fs::read(extracted.join("app/├⌐.bin")).unwrap(), b"payload"); + assert!(!extracted.join("app/é.bin").exists()); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn installs_cp437_utf8_and_unicode_path_block_map_names() { + use crate::zip_test::{archive, unicode_path_extra, Entry}; + + let raw = b"app/\xc3\xa9.bin"; + let unicode = unicode_path_extra(raw, "app/资源.bin".as_bytes(), true); + let bad_crc = unicode_path_extra(raw, "app/资源.bin".as_bytes(), false); + for (flags, extra, expected) in [ + (0, &[][..], "├⌐.bin"), + (1 << 11, &[][..], "é.bin"), + (0, unicode.as_slice(), "资源.bin"), + (0, bad_crc.as_slice(), "├⌐.bin"), + ] { + let root = temp_test_dir("encoded-msix-install"); + let msix = root.join("codex.msix"); + let installed = root.join("Codex"); + let block_map = format!( + r#" + + "# + ); + let manifest = br#" + + + "#; + let mut resource = Entry::new(raw, b"payload"); + resource.flags = flags; + resource.extra = extra; + fs::write( + &msix, + archive(&[ + Entry::new(b"AppxBlockMap.xml", block_map.as_bytes()), + Entry::new(b"AppxManifest.xml", manifest), + Entry::new(b"app/ChatGPT.exe", b"fake entry exe"), + resource, + ]), + ) + .unwrap(); + let report = install_portable_from_msix_inner(&msix, &installed, false, false).unwrap(); + assert!(report.success); + assert_eq!( + fs::read(installed.join("app").join(expected)).unwrap(), + b"payload" + ); + fs::remove_dir_all(root).unwrap(); + } + } + + #[test] + fn rejects_unsafe_unicode_path_during_msix_extraction() { + use crate::zip_test::{archive, unicode_path_extra, Entry}; + + let raw = b"app/safe.bin"; + for unsafe_name in [ + "../escape.bin", r"..\escape.bin", "/absolute", r"C:\escape", "nul\0path", + ] { + let root = temp_test_dir("unsafe-unicode-msix"); + let msix = root.join("codex.msix"); + let extracted = root.join("extracted"); + fs::create_dir_all(&extracted).unwrap(); + let extra = unicode_path_extra(raw, unsafe_name.as_bytes(), true); + let mut entry = Entry::new(raw, b"payload"); + entry.extra = &extra; + fs::write( + &msix, + archive(&[Entry::new(b"AppxManifest.xml", b""), entry]), + ) + .unwrap(); + assert!(extract_msix(&msix, &extracted).is_err(), "{unsafe_name:?}"); + assert!(!root.join("escape.bin").exists()); + fs::remove_dir_all(root).unwrap(); + } + } + #[test] fn extracts_percent_encoded_payload_to_block_map_logical_paths() { let root = temp_test_dir("block-map-logical-paths"); diff --git a/crates/test-support/zip.rs b/crates/test-support/zip.rs new file mode 100644 index 0000000..f9b7f4e --- /dev/null +++ b/crates/test-support/zip.rs @@ -0,0 +1,99 @@ +//! Stored ZIP fixtures with explicit filename bytes, flags and extra fields. +//! Do not use ZipWriter here: it sets the UTF-8 flag from the name's contents. + +pub struct Entry<'a> { + pub name: &'a [u8], + pub data: &'a [u8], + pub flags: u16, + pub extra: &'a [u8], + pub unix_mode: u32, +} + +impl<'a> Entry<'a> { + pub fn new(name: &'a [u8], data: &'a [u8]) -> Self { + Self { + name, + data, + flags: 0, + extra: &[], + unix_mode: 0o100644, + } + } +} + +fn crc32(bytes: &[u8]) -> u32 { + let mut crc = !0_u32; + for &byte in bytes { + crc ^= u32::from(byte); + for _ in 0..8 { + crc = (crc >> 1) ^ (0xedb8_8320 & 0_u32.wrapping_sub(crc & 1)); + } + } + !crc +} + +pub fn unicode_path_extra(raw_name: &[u8], unicode_name: &[u8], valid_crc: bool) -> Vec { + let mut extra = Vec::new(); + extra.extend(0x7075_u16.to_le_bytes()); + extra.extend(u16::try_from(5 + unicode_name.len()).unwrap().to_le_bytes()); + extra.push(1); + extra.extend((crc32(raw_name) ^ u32::from(!valid_crc)).to_le_bytes()); + extra.extend(unicode_name); + extra +} + +pub fn archive(entries: &[Entry<'_>]) -> Vec { + let mut bytes = Vec::new(); + let mut central = Vec::new(); + for entry in entries { + let offset = u32::try_from(bytes.len()).unwrap(); + let size = u32::try_from(entry.data.len()).unwrap(); + let name_len = u16::try_from(entry.name.len()).unwrap(); + let extra_len = u16::try_from(entry.extra.len()).unwrap(); + let crc = crc32(entry.data); + bytes.extend(b"PK\x03\x04"); + for value in [20_u16, entry.flags, 0, 0, 0] { + bytes.extend(value.to_le_bytes()); + } + for value in [crc, size, size] { + bytes.extend(value.to_le_bytes()); + } + bytes.extend(name_len.to_le_bytes()); + bytes.extend(extra_len.to_le_bytes()); + bytes.extend(entry.name); + bytes.extend(entry.extra); + bytes.extend(entry.data); + + central.extend(b"PK\x01\x02"); + for value in [(3 << 8) | 20_u16, 20, entry.flags, 0, 0, 0] { + central.extend(value.to_le_bytes()); + } + for value in [crc, size, size] { + central.extend(value.to_le_bytes()); + } + for value in [name_len, extra_len, 0, 0, 0] { + central.extend(value.to_le_bytes()); + } + central.extend((entry.unix_mode << 16).to_le_bytes()); + central.extend(offset.to_le_bytes()); + central.extend(entry.name); + central.extend(entry.extra); + } + let central_offset = u32::try_from(bytes.len()).unwrap(); + let central_len = u32::try_from(central.len()).unwrap(); + bytes.extend(central); + bytes.extend(b"PK\x05\x06"); + let count = u16::try_from(entries.len()).unwrap(); + for value in [0_u16, 0, count, count] { + bytes.extend(value.to_le_bytes()); + } + bytes.extend(central_len.to_le_bytes()); + bytes.extend(central_offset.to_le_bytes()); + bytes.extend(0_u16.to_le_bytes()); + bytes +} + +#[test] +fn fixture_crc_matches_the_standard_check_value() { + assert_eq!(crc32(b"123456789"), 0xcbf4_3926); +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3b336ea..142ff4f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tauri-build = { version = "2.6.2", features = [] } [dependencies] base64.workspace = true +codex-archive = { path = "../crates/codex-archive" } codex-mac-engine = { path = "../crates/codex-mac-engine" } codex-theme-engine = { path = "../crates/codex-theme-engine" } codex-win-engine = { path = "../crates/codex-win-engine" } diff --git a/src-tauri/src/app/mac_update.rs b/src-tauri/src/app/mac_update.rs index eb22b28..6e02c07 100644 --- a/src-tauri/src/app/mac_update.rs +++ b/src-tauri/src/app/mac_update.rs @@ -1007,11 +1007,10 @@ where let mut entry = archive .by_index(index) .map_err(|e| AppError::Engine(format!("读取 ZIP 条目失败: {e}")))?; - let entry_name = entry - .name() + let entry_name = codex_archive::entry_name(&entry) .map_err(|e| AppError::Engine(format!("读取 ZIP 条目名称失败: {e}")))? .into_owned(); - let Some(enclosed) = entry.enclosed_name() else { + let Some(enclosed) = codex_archive::enclosed_path(&entry_name) else { return Err(AppError::Engine(format!( "ZIP 包含不安全路径:{}", entry_name @@ -3257,6 +3256,79 @@ mod disk_preflight_tests { assert!(require_zip_expansion_space(expanded, work, |_| Ok(None)).is_ok()); } + #[test] + fn zip_preflight_validates_the_decoded_unicode_path() { + use crate::zip_test::{archive, unicode_path_extra, Entry}; + + let root = std::env::temp_dir().join(format!("cam-encoded-zip-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let package = root.join("Codex.zip"); + let raw = b"Codex.app/Contents/\xc3\xa9.bin"; + let unicode = unicode_path_extra(raw, "Codex.app/Contents/资源.bin".as_bytes(), true); + let bad_crc = unicode_path_extra(raw, b"../escape.bin", false); + for (flags, extra) in [ + (0, &[][..]), + (1 << 11, &[][..]), + (0, unicode.as_slice()), + (0, bad_crc.as_slice()), + ] { + let mut entry = Entry::new(raw, b"payload"); + entry.flags = flags; + entry.extra = extra; + std::fs::write(&package, archive(&[entry])).unwrap(); + preflight_app_zip_with_available(&package, &root, |_| Ok(Some(u64::MAX))).unwrap(); + } + + for unsafe_name in [ + "../escape.bin", r"..\escape.bin", "/absolute", r"C:\escape", "nul\0path", + ] { + let extra = unicode_path_extra(raw, unsafe_name.as_bytes(), true); + let mut entry = Entry::new(raw, b"payload"); + entry.extra = &extra; + std::fs::write(&package, archive(&[entry])).unwrap(); + let error = preflight_app_zip_with_available(&package, &root, |_| Ok(Some(u64::MAX))) + .unwrap_err(); + assert!(error.to_string().contains("不安全路径"), "{unsafe_name:?}: {error}"); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn zip_preflight_rejects_invalid_flagged_utf8() { + use crate::zip_test::{archive, Entry}; + + let root = std::env::temp_dir().join(format!("cam-invalid-zip-name-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let package = root.join("Codex.zip"); + let mut entry = Entry::new(b"Codex.app/Contents/caf\x82.bin", b"payload"); + entry.flags = 1 << 11; + std::fs::write(&package, archive(&[entry])).unwrap(); + let error = preflight_app_zip_with_available(&package, &root, |_| Ok(Some(u64::MAX))) + .unwrap_err(); + assert!(error.to_string().contains("名称失败"), "{error}"); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn zip_preflight_uses_cp437_name_in_symlink_checks() { + use crate::zip_test::{archive, Entry}; + + let root = std::env::temp_dir().join(format!("cam-cp437-symlink-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let package = root.join("Codex.zip"); + let mut entry = Entry::new(b"\xc3\xa9.app", b"/Applications/Codex.app"); + entry.unix_mode = 0o120777; + std::fs::write(&package, archive(&[entry])).unwrap(); + let error = preflight_app_zip_with_available(&package, &root, |_| Ok(Some(u64::MAX))) + .unwrap_err(); + let message = error.to_string(); + assert!( + message.contains("物理 .app") && message.contains("├⌐.app"), + "{error}" + ); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn zip_preflight_rejects_forged_small_sizes_before_ditto_can_expand_them() { use std::io::Write as _; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 111b22d..135e085 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,10 @@ pub mod errors; mod state; +#[cfg(test)] +#[path = "../../crates/test-support/zip.rs"] +mod zip_test; + use std::sync::atomic::Ordering; use tauri::webview::PageLoadEvent;