diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java
index d176f0ed..50a6b5e4 100644
--- a/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java
+++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/ZipSkillFetcher.java
@@ -8,8 +8,10 @@ import vip.mate.skill.runtime.SkillFrontmatterParser;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
+import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
+import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
@@ -179,11 +181,17 @@ public class ZipSkillFetcher {
private static final Charset GBK = Charset.isSupported("GBK") ? Charset.forName("GBK") : null;
/**
- * Decompress raw ZIP bytes, trying UTF-8 first and falling back to GBK when
- * an entry name fails to decode as UTF-8 — the common failure mode for zips
- * packaged on Chinese Windows, where filenames are GBK and UTF-8 decoding
- * throws a {@link CharacterCodingException}. Buffering the bytes (rather than
- * a one-shot stream) is what makes the retry possible.
+ * Decompress raw ZIP bytes into in-memory SKILL.md + references + scripts.
+ *
+ *
Entry names and entry content are each decoded independently — UTF-8
+ * first, falling back to GBK — rather than picking one charset for the
+ * whole archive. Zips packaged on Windows commonly mix encodings: the OS
+ * zip tool writes entry names in the local codepage (GBK) because it
+ * never sets the ZIP "language encoding flag", while file content
+ * authored in a UTF-8 text editor stays UTF-8. Deciding the charset once
+ * for the entire archive means a single GBK-named entry forces every
+ * already-correct UTF-8 file to be mis-decoded as GBK, turning valid
+ * Chinese text into mojibake.
*/
public static ExtractedSkill extract(byte[] zipBytes) throws IOException {
return extract(zipBytes, Limits.DEFAULT);
@@ -191,41 +199,17 @@ public class ZipSkillFetcher {
/** Variant of {@link #extract(byte[])} with explicit size caps. */
public static ExtractedSkill extract(byte[] zipBytes, Limits limits) throws IOException {
- try {
- return extract(zipBytes, StandardCharsets.UTF_8, limits);
- } catch (IOException | RuntimeException e) {
- if (GBK != null && isCharsetError(e)) {
- log.warn("[ZipSkillFetcher] UTF-8 entry decode failed, retrying with GBK (Windows-authored archive?)");
- return extract(zipBytes, GBK, limits);
- }
- throw e;
- }
- }
-
- /** True if {@code t} (or any cause) is a charset-decode failure, vs a genuine "no SKILL.md" error. */
- private static boolean isCharsetError(Throwable t) {
- for (Throwable c = t; c != null; c = c.getCause()) {
- if (c instanceof CharacterCodingException) {
- return true;
- }
- String m = c.getMessage();
- if (m != null && m.toLowerCase().contains("malformed")) {
- return true;
- }
- if (c.getCause() == c) {
- break;
- }
- }
- return false;
- }
-
- private static ExtractedSkill extract(byte[] zipBytes, Charset charset, Limits limits) throws IOException {
List raws = new ArrayList<>();
String skillMdContent = null;
String skillMdPrefix = "";
long totalSize = 0;
- try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) {
+ // ISO-8859-1 maps every byte value 0-255 to a distinct character, so
+ // ZipInputStream never throws while decoding a name with it — even a
+ // raw GBK-encoded name comes back as reversible mojibake that
+ // fixEntryName() below can re-decode itself, instead of aborting the
+ // whole archive read the way a strict UTF-8/GBK charset would.
+ try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), StandardCharsets.ISO_8859_1)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) {
@@ -233,7 +217,7 @@ public class ZipSkillFetcher {
continue;
}
- String entryName = entry.getName();
+ String entryName = fixEntryName(entry.getName());
// Skip macOS archive cruft so it doesn't surface as "ignored" noise.
if (entryName.startsWith("__MACOSX/") || entryName.equals(".DS_Store")
@@ -286,7 +270,7 @@ public class ZipSkillFetcher {
continue;
}
- String content = new String(bytes, charset);
+ String content = decodeBytes(bytes);
String normalizedName = entryPath.toString().replace('\\', '/');
String fileName = entryPath.getFileName().toString();
@@ -346,6 +330,64 @@ public class ZipSkillFetcher {
return new ExtractedSkill(skillMdContent, references, scripts);
}
+ /**
+ * Re-decode a ZIP entry name that {@link ZipInputStream} returned via the
+ * byte-preserving ISO-8859-1 read. If the archive's language-encoding
+ * flag was actually set for this entry, the JDK already forced a real
+ * UTF-8 decode and the name contains genuine non-Latin-1 characters —
+ * left untouched. Otherwise the name is just the raw bytes reinterpreted
+ * as Latin-1 (fully reversible), so the original bytes are recovered and
+ * decoded again, trying UTF-8 then falling back to GBK.
+ */
+ private static String fixEntryName(String name) {
+ if (!isLatin1(name)) {
+ return name;
+ }
+ return decodeBytes(name.getBytes(StandardCharsets.ISO_8859_1));
+ }
+
+ private static boolean isLatin1(String s) {
+ for (int i = 0; i < s.length(); i++) {
+ if (s.charAt(i) > 0xFF) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Decode raw bytes as text, trying UTF-8 first and falling back to GBK —
+ * per entry, not per archive, so one mis-encoded name or file doesn't
+ * force a lossy re-decode of everything else that was already correct.
+ * Falls back to a lossy UTF-8 decode (replacement characters) only if
+ * neither charset parses cleanly.
+ */
+ private static String decodeBytes(byte[] bytes) {
+ String utf8 = tryDecodeStrict(bytes, StandardCharsets.UTF_8);
+ if (utf8 != null) {
+ return utf8;
+ }
+ if (GBK != null) {
+ String gbk = tryDecodeStrict(bytes, GBK);
+ if (gbk != null) {
+ return gbk;
+ }
+ }
+ return new String(bytes, StandardCharsets.UTF_8);
+ }
+
+ private static String tryDecodeStrict(byte[] bytes, Charset charset) {
+ try {
+ return charset.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(bytes))
+ .toString();
+ } catch (CharacterCodingException e) {
+ return null;
+ }
+ }
+
/**
* Heuristic binary detector: an entry is treated as binary if a NUL byte
* (0x00) appears within the inspected prefix. UTF-8 and GBK text never
diff --git a/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java b/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java
index 73a6ed90..3187ad01 100644
--- a/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/skill/installer/ZipSkillFetcherTest.java
@@ -268,6 +268,48 @@ class ZipSkillFetcherTest {
assertEquals("# 中文内容\n", ex.references().get("中文说明.md"));
}
+ @Test
+ @DisplayName("issue #534: GBK-named entry alongside genuinely UTF-8 content — UTF-8 must not be corrupted")
+ void mixedGbkNameAndUtf8ContentDoesNotCorruptUtf8() throws IOException {
+ org.junit.jupiter.api.Assumptions.assumeTrue(
+ java.nio.charset.Charset.isSupported("GBK"), "GBK charset not available on this JVM");
+ java.nio.charset.Charset gbk = java.nio.charset.Charset.forName("GBK");
+
+ String description = "这是一个测试技能,用于复现乱码问题";
+ String skillMdWithDescription = """
+ ---
+ name: tencent-meeting
+ description: %s
+ version: 1.0.0
+ ---
+ # Test skill
+ """.formatted(description);
+
+ // Windows zip tools commonly write entry names in the local codepage
+ // (GBK) without setting the ZIP UTF-8 flag, even when the underlying
+ // file content was authored in UTF-8 by a text editor. Encoding the
+ // whole ZipOutputStream with GBK reproduces that: the entry NAME
+ // "参考资料.md" is GBK bytes, while SKILL.md's CONTENT stays UTF-8.
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ZipOutputStream zos = new ZipOutputStream(baos, gbk)) {
+ zos.putNextEntry(new ZipEntry("SKILL.md"));
+ zos.write(skillMdWithDescription.getBytes(StandardCharsets.UTF_8));
+ zos.closeEntry();
+
+ zos.putNextEntry(new ZipEntry("references/参考资料.md"));
+ zos.write("中文参考内容".getBytes(StandardCharsets.UTF_8));
+ zos.closeEntry();
+ }
+
+ ZipSkillFetcher.ExtractedSkill ex = ZipSkillFetcher.extract(baos.toByteArray());
+
+ assertTrue(ex.skillMdContent().contains(description),
+ "SKILL.md's genuinely UTF-8 content must not be re-decoded as GBK "
+ + "just because a different entry's name needed the GBK fallback");
+ assertEquals(1, ex.references().size());
+ assertEquals("中文参考内容", ex.references().get("参考资料.md"));
+ }
+
@Test
@DisplayName("configurable per-entry cap: entry over the default 1MB survives when the cap is raised")
void raisedEntryCapKeepsLargeEntry() throws IOException {