diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/EimLoader.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/EimLoader.java
index ea5218bb5..62a8f4509 100644
--- a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/EimLoader.java
+++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/EimLoader.java
@@ -6,7 +6,6 @@
import java.io.BufferedReader;
import java.io.File;
-import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
@@ -21,8 +20,6 @@
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Optional;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
@@ -389,32 +386,7 @@ private void cleanupDownloadDirectory()
private Path unzip(Path zipPath, Path destDir) throws IOException
{
- Files.createDirectories(destDir);
- Path firstExecutable = null;
-
- try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipPath.toFile())))
- {
- ZipEntry entry;
- while ((entry = zis.getNextEntry()) != null)
- {
- Path newPath = destDir.resolve(entry.getName());
- if (entry.isDirectory())
- {
- Files.createDirectories(newPath);
- }
- else
- {
- Files.createDirectories(newPath.getParent());
- Files.copy(zis, newPath, StandardCopyOption.REPLACE_EXISTING);
- if (firstExecutable == null && Files.isRegularFile(newPath))
- {
- newPath.toFile().setExecutable(true);
- firstExecutable = newPath;
- }
- }
- }
- }
- return firstExecutable != null ? firstExecutable : destDir;
+ return EimZipExtractor.extract(zipPath, destDir);
}
private String readProcessOutput(Process p) throws IOException
diff --git a/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/EimZipExtractor.java b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/EimZipExtractor.java
new file mode 100644
index 000000000..65f848abf
--- /dev/null
+++ b/bundles/com.espressif.idf.core/src/com/espressif/idf/core/tools/EimZipExtractor.java
@@ -0,0 +1,257 @@
+/*******************************************************************************
+ * Copyright 2026 Espressif Systems (Shanghai) PTE LTD. All rights reserved.
+ * Use is subject to license terms.
+ *******************************************************************************/
+package com.espressif.idf.core.tools;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.Optional;
+import java.util.Set;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import org.eclipse.core.runtime.Platform;
+
+import com.espressif.idf.core.logging.Logger;
+
+/**
+ * Extracts EIM release zip archives.
+ *
+ * Newer Linux/macOS packages ship a versioned binary ({@code eim_vX.Y.Z}) plus an {@code eim}
+ * symlink. {@link ZipInputStream} materializes that symlink as a tiny regular file whose contents
+ * are the link target. Running that file with arguments (e.g. {@code eim select …}) does not
+ * forward argv to the real binary and can open the GUI. This extractor detects that case and
+ * recreates a real symlink (or copies the target if symlinks are unsupported).
+ *
+ * Older packages ship only a plain {@code eim} binary — left unchanged.
+ *
+ * Launch-path selection considers only files from the current archive, so leftover binaries in
+ * {@code destDir} from a previous install are not chosen.
+ */
+public final class EimZipExtractor
+{
+ /**
+ * Symlink targets stored as ZIP entry payloads are short path strings (e.g. {@code eim_v0.17.4}).
+ * Real EIM binaries are multi‑MB; keep this well below any plausible binary size.
+ */
+ private static final long MAX_SYMLINK_PAYLOAD_BYTES = 512;
+
+ private EimZipExtractor()
+ {
+ }
+
+ /**
+ * Extracts {@code zipPath} into {@code destDir} and returns the preferred EIM launch path
+ * ({@code eim} / {@code eim.exe} when present in this archive, otherwise a versioned
+ * {@code eim_v*} binary from this archive).
+ */
+ public static Path extract(Path zipPath, Path destDir) throws IOException
+ {
+ Files.createDirectories(destDir);
+ Set extractedFiles = new LinkedHashSet<>();
+ Path firstRegularFile = null;
+
+ try (InputStream fileIn = Files.newInputStream(zipPath); ZipInputStream zis = new ZipInputStream(fileIn))
+ {
+ ZipEntry entry;
+ while ((entry = zis.getNextEntry()) != null)
+ {
+ Path newPath = destDir.resolve(entry.getName()).normalize();
+ if (!newPath.startsWith(destDir.normalize()))
+ {
+ throw new IOException("ZIP entry is outside target dir: " + entry.getName()); //$NON-NLS-1$
+ }
+
+ if (entry.isDirectory())
+ {
+ Files.createDirectories(newPath);
+ continue;
+ }
+
+ Files.createDirectories(newPath.getParent());
+ Files.copy(zis, newPath, StandardCopyOption.REPLACE_EXISTING);
+ extractedFiles.add(newPath);
+ if (shouldMarkExecutable(newPath))
+ {
+ newPath.toFile().setExecutable(true);
+ }
+ if (firstRegularFile == null)
+ {
+ firstRegularFile = newPath;
+ }
+ }
+ }
+
+ repairMaterializedEimSymlink(destDir, extractedFiles);
+ return resolvePreferredLaunchPath(destDir, extractedFiles, firstRegularFile);
+ }
+
+ /**
+ * If {@code eim} was extracted from this archive as a tiny text file whose content names an
+ * existing sibling binary (the usual result of extracting a ZIP symlink with
+ * {@link ZipInputStream}), replace it with a real symlink or a copy of that target.
+ */
+ public static void repairMaterializedEimSymlink(Path destDir) throws IOException
+ {
+ Path eim = destDir.resolve("eim").normalize(); //$NON-NLS-1$
+ repairMaterializedEimSymlink(destDir, Set.of(eim));
+ }
+
+ static void repairMaterializedEimSymlink(Path destDir, Collection extractedFromArchive) throws IOException
+ {
+ Path eim = destDir.resolve("eim").normalize(); //$NON-NLS-1$
+ if (!extractedFromArchive.contains(eim))
+ {
+ return;
+ }
+ if (!Files.isRegularFile(eim) || Files.isSymbolicLink(eim))
+ {
+ return;
+ }
+
+ long size = Files.size(eim);
+ if (size == 0 || size > MAX_SYMLINK_PAYLOAD_BYTES)
+ {
+ return;
+ }
+
+ byte[] bytes = Files.readAllBytes(eim);
+ if (containsNullByte(bytes))
+ {
+ return;
+ }
+
+ String target = new String(bytes, StandardCharsets.UTF_8).trim();
+ if (!isSafeSymlinkTargetName(target))
+ {
+ return;
+ }
+
+ Path targetPath = destDir.resolve(target).normalize();
+ if (!targetPath.startsWith(destDir.normalize()) || !Files.isRegularFile(targetPath)
+ || Files.isSameFile(eim, targetPath))
+ {
+ return;
+ }
+
+ if (!looksLikeEimBinaryName(targetPath.getFileName().toString()))
+ {
+ return;
+ }
+
+ Files.delete(eim);
+ try
+ {
+ Files.createSymbolicLink(eim, Path.of(target));
+ Logger.log("Restored EIM symlink " + eim + " -> " + target); //$NON-NLS-1$ //$NON-NLS-2$
+ }
+ catch (UnsupportedOperationException | IOException e)
+ {
+ Logger.log("Could not create EIM symlink; copying target instead: " + e.getMessage()); //$NON-NLS-1$
+ Files.copy(targetPath, eim, StandardCopyOption.REPLACE_EXISTING);
+ eim.toFile().setExecutable(true);
+ }
+ }
+
+ public static boolean looksLikeMaterializedSymlinkPayload(Path file, Path destDir) throws IOException
+ {
+ if (!Files.isRegularFile(file) || Files.isSymbolicLink(file))
+ {
+ return false;
+ }
+ long size = Files.size(file);
+ if (size == 0 || size > MAX_SYMLINK_PAYLOAD_BYTES)
+ {
+ return false;
+ }
+ byte[] bytes = Files.readAllBytes(file);
+ if (containsNullByte(bytes))
+ {
+ return false;
+ }
+ String target = new String(bytes, StandardCharsets.UTF_8).trim();
+ if (!isSafeSymlinkTargetName(target))
+ {
+ return false;
+ }
+ Path targetPath = destDir.resolve(target).normalize();
+ return targetPath.startsWith(destDir.normalize()) && Files.isRegularFile(targetPath)
+ && looksLikeEimBinaryName(targetPath.getFileName().toString());
+ }
+
+ /**
+ * Shared validation for ZIP-symlink payloads: reject empty names, path separators, parent
+ * references, and stray newlines so detection and repair stay aligned.
+ */
+ public static boolean isSafeSymlinkTargetName(String target)
+ {
+ return !target.isEmpty() && !target.contains("\n") && !target.contains("\r") //$NON-NLS-1$ //$NON-NLS-2$
+ && !target.contains("/") && !target.contains("\\") && !target.contains(".."); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
+ }
+
+ private static boolean shouldMarkExecutable(Path extractedFile)
+ {
+ if (Platform.OS_WIN32.equals(Platform.getOS()))
+ {
+ return false;
+ }
+ return looksLikeEimBinaryName(extractedFile.getFileName().toString());
+ }
+
+ private static boolean looksLikeEimBinaryName(String name)
+ {
+ String lower = name.toLowerCase();
+ return lower.equals("eim") || lower.startsWith("eim_v") || lower.startsWith("eim-"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
+ }
+
+ private static boolean containsNullByte(byte[] bytes)
+ {
+ for (byte b : bytes)
+ {
+ if (b == 0)
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static Path resolvePreferredLaunchPath(Path destDir, Collection extractedFromArchive,
+ Path firstRegularFile)
+ {
+ Path stableEim = destDir.resolve("eim").normalize(); //$NON-NLS-1$
+ if (extractedFromArchive.contains(stableEim) && Files.exists(stableEim))
+ {
+ return stableEim;
+ }
+
+ Path stableEimExe = destDir.resolve("eim.exe").normalize(); //$NON-NLS-1$
+ if (extractedFromArchive.contains(stableEimExe) && Files.exists(stableEimExe))
+ {
+ return stableEimExe;
+ }
+
+ Optional versioned = findVersionedEimBinary(extractedFromArchive);
+ if (versioned.isPresent())
+ {
+ return versioned.get();
+ }
+
+ return firstRegularFile != null ? firstRegularFile : destDir;
+ }
+
+ private static Optional findVersionedEimBinary(Collection extractedFromArchive)
+ {
+ return extractedFromArchive.stream().filter(Files::isRegularFile)
+ .filter(p -> p.getFileName().toString().matches("(?i)eim_v.+")) //$NON-NLS-1$
+ .sorted(Comparator.comparing(p -> p.getFileName().toString())).findFirst();
+ }
+}
diff --git a/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/tools/test/EimZipExtractorTest.java b/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/tools/test/EimZipExtractorTest.java
new file mode 100644
index 000000000..92a36ed48
--- /dev/null
+++ b/tests/com.espressif.idf.core.test/src/com/espressif/idf/core/tools/test/EimZipExtractorTest.java
@@ -0,0 +1,192 @@
+/*******************************************************************************
+ * Copyright 2026 Espressif Systems (Shanghai) PTE LTD. All rights reserved.
+ * Use is subject to license terms.
+ *******************************************************************************/
+package com.espressif.idf.core.tools.test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import org.eclipse.core.runtime.Platform;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import com.espressif.idf.core.tools.EimZipExtractor;
+
+class EimZipExtractorTest
+{
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void extractsPlainEimBinaryFromLegacyZip() throws Exception
+ {
+ Path zip = tempDir.resolve("legacy-eim.zip");
+ Path dest = tempDir.resolve("out-legacy");
+ // Payload larger than the symlink heuristic threshold and not a sibling path name.
+ String payload = "A".repeat(1024);
+ writeSingleFileZip(zip, "eim", payload);
+
+ Path launchPath = EimZipExtractor.extract(zip, dest);
+
+ Path eim = dest.resolve("eim");
+ assertEquals(eim, launchPath);
+ assertTrue(Files.isRegularFile(eim));
+ assertFalse(Files.isSymbolicLink(eim));
+ assertEquals(payload, Files.readString(eim));
+ if (!Platform.OS_WIN32.equals(Platform.getOS()))
+ {
+ assertTrue(Files.isExecutable(eim));
+ }
+ }
+
+ @Test
+ void repairsMaterializedSymlinkFromVersionedZip() throws Exception
+ {
+ Path zip = tempDir.resolve("versioned-eim.zip");
+ Path dest = tempDir.resolve("out-versioned");
+ // Simulate ZipInputStream behavior: symlink stored as a tiny regular file with target text.
+ writeTwoFileZip(zip, "eim_v0.17.4", "real-binary-bytes", "eim", "eim_v0.17.4");
+
+ Path launchPath = EimZipExtractor.extract(zip, dest);
+
+ Path versioned = dest.resolve("eim_v0.17.4");
+ Path eim = dest.resolve("eim");
+ assertTrue(Files.isRegularFile(versioned));
+ assertEquals(eim, launchPath);
+ assertEquals("real-binary-bytes", Files.readString(versioned));
+
+ if (Platform.OS_WIN32.equals(Platform.getOS()))
+ {
+ // Fallback on Windows: copy of the versioned binary (symlink may be unavailable).
+ assertTrue(Files.isRegularFile(eim));
+ assertEquals("real-binary-bytes", Files.readString(eim));
+ }
+ else
+ {
+ assertTrue(Files.isSymbolicLink(eim));
+ assertEquals(Path.of("eim_v0.17.4"), Files.readSymbolicLink(eim));
+ assertEquals("real-binary-bytes", Files.readString(eim));
+ assertTrue(Files.isExecutable(versioned));
+ }
+ }
+
+ @Test
+ void doesNotTreatLargeEimFileAsSymlinkPayload() throws Exception
+ {
+ Path dest = tempDir.resolve("probe");
+ Files.createDirectories(dest);
+ Path versioned = dest.resolve("eim_v0.17.4");
+ Files.writeString(versioned, "real-binary");
+ Path eim = dest.resolve("eim");
+ Files.writeString(eim, "X".repeat(1024));
+
+ assertFalse(EimZipExtractor.looksLikeMaterializedSymlinkPayload(eim, dest));
+ EimZipExtractor.repairMaterializedEimSymlink(dest);
+ assertFalse(Files.isSymbolicLink(eim));
+ assertEquals("X".repeat(1024), Files.readString(eim));
+ }
+
+ @Test
+ void detectsTinySiblingPathPayloadAsMaterializedSymlink() throws Exception
+ {
+ Path dest = tempDir.resolve("probe2");
+ Files.createDirectories(dest);
+ Files.writeString(dest.resolve("eim_v0.17.4"), "real-binary");
+ Path eim = dest.resolve("eim");
+ Files.writeString(eim, "eim_v0.17.4");
+
+ assertTrue(EimZipExtractor.looksLikeMaterializedSymlinkPayload(eim, dest));
+ }
+
+ @Test
+ void rejectsUnsafeSymlinkTargetNamesInDetectionAndRepair() throws Exception
+ {
+ Path dest = tempDir.resolve("probe-unsafe");
+ Files.createDirectories(dest);
+ Files.writeString(dest.resolve("eim_v..0"), "real-binary");
+
+ Path eim = dest.resolve("eim");
+ Files.writeString(eim, "eim_v..0");
+
+ assertFalse(EimZipExtractor.isSafeSymlinkTargetName("eim_v..0"));
+ assertFalse(EimZipExtractor.isSafeSymlinkTargetName("eim\r_v0.17.4"));
+ assertFalse(EimZipExtractor.looksLikeMaterializedSymlinkPayload(eim, dest));
+
+ EimZipExtractor.repairMaterializedEimSymlink(dest);
+ assertFalse(Files.isSymbolicLink(eim));
+ assertEquals("eim_v..0", Files.readString(eim));
+ }
+
+ @Test
+ void prefersLaunchPathFromCurrentArchiveOverPreexistingFiles() throws Exception
+ {
+ Path dest = tempDir.resolve("update");
+ Files.createDirectories(dest);
+ Files.writeString(dest.resolve("eim"), "old-stable-eim");
+ Files.writeString(dest.resolve("eim_v0.16.0"), "old-versioned");
+
+ Path zip = tempDir.resolve("new-only-versioned.zip");
+ writeSingleFileZip(zip, "eim_v0.17.4", "new-binary");
+
+ Path launchPath = EimZipExtractor.extract(zip, dest);
+
+ assertEquals(dest.resolve("eim_v0.17.4"), launchPath);
+ assertEquals("new-binary", Files.readString(launchPath));
+ assertEquals("old-stable-eim", Files.readString(dest.resolve("eim")));
+ assertEquals("old-versioned", Files.readString(dest.resolve("eim_v0.16.0")));
+ }
+
+ @Test
+ void doesNotRepairPreexistingEimWhenNotInCurrentArchive() throws Exception
+ {
+ Path dest = tempDir.resolve("leftover-symlink-payload");
+ Files.createDirectories(dest);
+ Files.writeString(dest.resolve("eim_v0.16.0"), "old-binary");
+ Path eim = dest.resolve("eim");
+ Files.writeString(eim, "eim_v0.16.0");
+
+ Path zip = tempDir.resolve("new-version-only.zip");
+ writeSingleFileZip(zip, "eim_v0.17.4", "new-binary");
+
+ Path launchPath = EimZipExtractor.extract(zip, dest);
+
+ assertEquals(dest.resolve("eim_v0.17.4"), launchPath);
+ assertFalse(Files.isSymbolicLink(eim));
+ assertEquals("eim_v0.16.0", Files.readString(eim));
+ }
+
+ private static void writeSingleFileZip(Path zipPath, String entryName, String payload) throws IOException
+ {
+ try (OutputStream out = Files.newOutputStream(zipPath); ZipOutputStream zos = new ZipOutputStream(out))
+ {
+ zos.putNextEntry(new ZipEntry(entryName));
+ zos.write(payload.getBytes(StandardCharsets.UTF_8));
+ zos.closeEntry();
+ }
+ }
+
+ private static void writeTwoFileZip(Path zipPath, String firstName, String firstPayload, String secondName,
+ String secondPayload) throws IOException
+ {
+ try (OutputStream out = Files.newOutputStream(zipPath); ZipOutputStream zos = new ZipOutputStream(out))
+ {
+ zos.putNextEntry(new ZipEntry(firstName));
+ zos.write(firstPayload.getBytes(StandardCharsets.UTF_8));
+ zos.closeEntry();
+
+ zos.putNextEntry(new ZipEntry(secondName));
+ zos.write(secondPayload.getBytes(StandardCharsets.UTF_8));
+ zos.closeEntry();
+ }
+ }
+}