fix(archive): handle multibyte ZIP paths on Windows (#2516)

zip 9.1.3's reserved-device-name check sliced entry names at byte offsets, so a WAR or tool archive with a multi-byte file name (e.g. `ZC机柜.jpg`) panicked with "end byte index is not a char boundary" on Windows. Raise zip to 9.2.3, which checks on character boundaries; this covers Tomcat WAR deployment and ZIP/VSIX tool extraction, the two enclosed_name callers.

Fixes #2456

Co-authored-by: xtr-hub <242808782+xtr-hub@users.noreply.github.com>
This commit is contained in:
xtr-hub
2026-11-10 23:14:04 +08:00
committed by GitHub
co-authored by xtr-hub
parent 636b2e1572
commit 8b32df9773
4 changed files with 64 additions and 3 deletions
Generated
+3 -2
View File
@@ -12152,14 +12152,15 @@ dependencies = [
[[package]]
name = "zip"
version = "9.1.3"
version = "9.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
checksum = "00fb4744e923442ecadfd3f85f51a5d24e03bf43d53813b81ebb4c91d02baa62"
dependencies = [
"crc32fast",
"flate2",
"getrandom 0.4.3",
"indexmap 2.14.2",
"libc",
"memchr",
"nix 0.31.3",
"typed-path",
+1 -1
View File
@@ -82,7 +82,7 @@ trash = "5"
blake3 = "1"
dunce = "1"
dirs = "6"
zip = { version = "9", default-features = false, features = ["deflate"] }
zip = { version = "9.2.3", default-features = false, features = ["deflate"] }
# search, index, language
grep-searcher = "0.1"
+42
View File
@@ -1228,6 +1228,48 @@ mod tests {
}
zip.finish().unwrap();
}
#[test]
fn explodes_wars_with_multibyte_path_components() {
let tmp = tempfile::tempdir().unwrap();
let war = tmp.path().join("app.war");
let entries = [
("bdms300-front/dist/img/ZC机柜.jpg", "reported case"),
("img/A机柜.jpg", "one ASCII byte"),
("img/😀.jpg", "four-byte character"),
("img/abé.jpg", "two-byte character"),
("ZC机柜/index.jsp", "multibyte directory"),
("img/普通.jpg", "character boundary"),
];
archive(&war, &entries);
for resources_only in [false, true] {
let destination = tmp.path().join(format!("out-{resources_only}"));
explode(&war, &destination, resources_only).unwrap();
for (name, text) in entries {
assert_eq!(fs::read_to_string(destination.join(name)).unwrap(), text, "{name}");
}
}
}
#[test]
fn rejects_war_path_traversal() {
let tmp = tempfile::tempdir().unwrap();
let war = tmp.path().join("app.war");
archive(&war, &[("../escape.txt", "outside")]);
assert!(explode(&war, &tmp.path().join("out"), false).is_err());
assert!(!tmp.path().join("escape.txt").exists());
}
#[cfg(windows)]
#[test]
fn rejects_war_reserved_device_paths() {
let tmp = tempfile::tempdir().unwrap();
let war = tmp.path().join("app.war");
for name in ["CON.txt", "NUL", "COM1.txt", "LPT¹", "img/aux.jpg"] {
archive(&war, &[(name, "device")]);
assert!(explode(&war, &tmp.path().join("out"), false).is_err(), "{name}");
}
}
#[test]
fn keeps_the_apps_own_context_descriptor() {
let tmp = tempfile::tempdir().unwrap();
+18
View File
@@ -443,6 +443,24 @@ mod tests {
}
}
#[test]
fn extracts_zip_with_multibyte_path_components() {
let tmp = tempfile::tempdir().unwrap();
let archive = tmp.path().join("tool.zip");
let entries: &[(&str, &[u8], u32)] = &[
("bdms300-front/dist/img/ZC机柜.jpg", b"reported case", 0o644),
("A机柜/file", b"directory", 0o644),
("abé.txt", b"two-byte character", 0o644),
("😀.txt", b"four-byte character", 0o644),
];
zip_file(&archive, entries);
let dest = tmp.path().join("out");
extract(&archive, ArchiveKind::Zip, &dest, "x", &CancellationToken::new()).unwrap();
for (name, data, _) in entries {
assert_eq!(fs::read(dest.join(name)).unwrap(), *data, "{name}");
}
}
#[test]
fn extracts_zip_with_modes_and_nested_dirs() {
let tmp = tempfile::tempdir().unwrap();