Repository: zaproxy/zaproxy
Issue: Do not remove add-ons from the install dir

### Describe the bug

We document the `-dir` option as a way to start ZAP with a clean config: https://www.zaproxy.org/docs/desktop/cmdline/
However on some platforms add-ons are deleted from the install dir when they are updated.
If the user updates one of the mandatory add-ons then it breaks this option - ZAP will not start.

### Steps to reproduce the behavior

1. Install ZAP
2. Update ZAP (making sure at least one mandatory option is updated)
3. Run ZAP using the `-d` option

### Expected behavior

ZAP will start with all of the orignal add-ons

### Software versions

2.12.0

### Screenshots

_No response_

### Errors from the zap.log file

_No response_

### Additional context

_No response_

### Would you like to help fix this issue?

- [ ] Yes

Changed files:
zap/src/main/java/org/zaproxy/zap/control/AddOnLoader.java, zap/src/test/java/org/zaproxy/zap/control/AddOnLoaderUnitTest.java, zap/src/test/java/org/zaproxy/zap/control/AddOnTestUtils.java

diff --git a/zap/src/main/java/org/zaproxy/zap/control/AddOnLoader.java b/zap/src/main/java/org/zaproxy/zap/control/AddOnLoader.java
index 9c1d18361..24a3453ea 100644
--- a/zap/src/main/java/org/zaproxy/zap/control/AddOnLoader.java
+++ b/zap/src/main/java/org/zaproxy/zap/control/AddOnLoader.java
@@ -23,6 +23,7 @@ import java.awt.EventQueue;
 import java.io.File;
 import java.io.FileFilter;
 import java.io.FilenameFilter;
+import java.io.IOException;
 import java.lang.reflect.Constructor;
 import java.lang.reflect.Modifier;
 import java.net.MalformedURLException;
@@ -30,6 +31,9 @@ import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
 import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -713,15 +717,56 @@ public class AddOnLoader extends URLClassLoader {
     private void deleteAddOn(AddOn addOn, boolean upgrading) {
         AddOnInstaller.uninstallAddOnLibs(addOn);
 
+        File addOnFile = addOn.getFile();
+        if (addOnFile == null || !addOnFile.exists()) {
+            return;
+        }
+
+        if (isInstallationAddOn(addOnFile.toPath())) {
+            LOGGER.debug(
+                    "Not removing add-on in the installation directory: {}",
+                    addOnFile.getAbsolutePath());
+            if (!upgrading) {
+                saveBlockedAddOn(addOn);
+            }
+            return;
+        }
+
         if (addOn.getFile() != null && addOn.getFile().exists()) {
             if (!addOn.getFile().delete() && !upgrading) {
                 LOGGER.debug("Can't delete {}", addOn.getFile().getAbsolutePath());
-                this.blockList.add(addOn.getId());
-                this.saveBlockList();
+                saveBlockedAddOn(addOn);
             }
         }
     }
 
+    private void saveBlockedAddOn(AddOn addOn) {
+        if (addOn.isMandatory()) {
+            return;
+        }
+
+        blockList.add(addOn.getId());
+        saveBlockList();
+    }
+
+    List<String> getBlockList() {
+        return blockList;
+    }
+
+    private static boolean isInstallationAddOn(Path file) {
+        Path installDir = Paths.get(Constant.getZapInstall()).resolve(Constant.FOLDER_PLUGIN);
+        if (Files.notExists(installDir)) {
+            return false;
+        }
+
+        try {
+            return Files.isSameFile(installDir, file.getParent());
+        } catch (IOException e) {
+            LOGGER.warn("An error occurred while checking the add-on's dir:", e);
+            return false;
+        }
+    }
+
     private void removeAddOnClassLoader(AddOn addOn) {
         if (this.addOnLoaders.containsKey(addOn.getId())) {
             try (AddOnClassLoader addOnClassLoader = this.addOnLoaders.remove(addOn.getId())) {
diff --git a/zap/src/test/java/org/zaproxy/zap/control/AddOnLoaderUnitTest.java b/zap/src/test/java/org/zaproxy/zap/control/AddOnLoaderUnitTest.java
index 367c63e63..8f48533e7 100644
--- a/zap/src/test/java/org/zaproxy/zap/control/AddOnLoaderUnitTest.java
+++ b/zap/src/test/java/org/zaproxy/zap/control/AddOnLoaderUnitTest.java
@@ -25,12 +25,15 @@ import static org.hamcrest.Matchers.empty;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.hasSize;
 import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.mockito.Mockito.verify;
 
 import java.io.File;
 import java.io.IOException;
+import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -56,6 +59,7 @@ class AddOnLoaderUnitTest extends AddOnTestUtils {
     @BeforeEach
     void createZapHome() throws Exception {
         Constant.setZapHome(newTempDir("home").toAbsolutePath().toString());
+        Constant.setZapInstall(newTempDir("install").toAbsolutePath().toString());
     }
 
     @ParameterizedTest
@@ -323,6 +327,52 @@ class AddOnLoaderUnitTest extends AddOnTestUtils {
                 is(equalTo(InstallationStatus.INSTALLED)));
     }
 
+    @Test
+    void shouldRemoveAddOnWhenNotInInstallationPluginDir() throws Exception {
+        // Given
+        Path dir = newTempDir();
+        String addOnId = "id";
+        createAddOnFile(dir, addOnId + ".zap");
+        AddOnLoader addOnLoader = new AddOnLoader(new File[] {dir.toFile()});
+        AddOn addOn = getAddOn(addOnLoader, addOnId);
+        // When
+        addOnLoader.removeAddOn(addOn, false, AddOnLoader.NULL_CALLBACK);
+        // Then
+        assertThat(Files.exists(addOn.getFile().toPath()), is(equalTo(false)));
+        assertThat(addOnLoader.getBlockList(), not(contains("id")));
+    }
+
+    @Test
+    void shouldKeepAndBlockAddOnInInstallationPluginDir() throws Exception {
+        // Given
+        Path dir = Paths.get(Constant.getZapInstall(), Constant.FOLDER_PLUGIN);
+        String addOnId = "id";
+        createAddOnFile(dir, addOnId + ".zap");
+        AddOnLoader addOnLoader = new AddOnLoader(new File[] {dir.toFile()});
+        AddOn addOn = getAddOn(addOnLoader, addOnId);
+        // When
+        addOnLoader.removeAddOn(addOn, false, AddOnLoader.NULL_CALLBACK);
+        // Then
+        assertThat(Files.exists(addOn.getFile().toPath()), is(equalTo(true)));
+        assertThat(addOnLoader.getBlockList(), contains("id"));
+    }
+
+    @Test
+    void shouldKeepButNotBlockMandatoryAddOnInInstallationPluginDir() throws Exception {
+        // Given
+        Path dir = Paths.get(Constant.getZapInstall(), Constant.FOLDER_PLUGIN);
+        String addOnId = "id";
+        createAddOnFile(dir, addOnId + ".zap");
+        AddOnLoader addOnLoader = new AddOnLoader(new File[] {dir.toFile()});
+        AddOn addOn = getAddOn(addOnLoader, addOnId);
+        addOn.setMandatory(true);
+        // When
+        addOnLoader.removeAddOn(addOn, false, AddOnLoader.NULL_CALLBACK);
+        // Then
+        assertThat(Files.exists(addOn.getFile().toPath()), is(equalTo(true)));
+        assertThat(addOnLoader.getBlockList(), not(contains("id")));
+    }
+
     private void assertExtensionRemoved(Extension extension) {
         verify(extensionLoader).removeExtension(extension);
     }
diff --git a/zap/src/test/java/org/zaproxy/zap/control/AddOnTestUtils.java b/zap/src/test/java/org/zaproxy/zap/control/AddOnTestUtils.java
index 9d6d80804..125aa29ae 100644
--- a/zap/src/test/java/org/zaproxy/zap/control/AddOnTestUtils.java
+++ b/zap/src/test/java/org/zaproxy/zap/control/AddOnTestUtils.java
@@ -152,6 +152,7 @@ class AddOnTestUtils extends WithConfigsTest {
             Consumer<StringBuilder> manifestConsumer,
             Consumer<ZipOutputStream> addOnConsumer) {
         try {
+            Files.createDirectories(dir);
             Path file = dir.resolve(fileName);
             try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(file))) {
                 ZipEntry manifest = new ZipEntry(AddOn.MANIFEST_FILE_NAME);