This is an automated email from the ASF dual-hosted git repository.

epugh pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/branch_10x by this push:
     new 851b1b00220 SOLR-16341: fix blank file zip handling (#4249)
851b1b00220 is described below

commit 851b1b00220dc3f2d55942ea64ab089a60114073
Author: Eric Pugh <[email protected]>
AuthorDate: Sun Jun 21 07:50:33 2026 -0400

    SOLR-16341: fix blank file zip handling (#4249)
    
    Co-authored-by: copilot-swe-agent[bot] 
<[email protected]>
    Co-authored-by: epugh <[email protected]>
    Co-authored-by: Copilot <[email protected]>
---
 .../SOLR-16341-fix-blank-file-zip-handling.yml     |   8 ++
 .../solr/handler/configsets/UploadConfigSet.java   |  51 ++++++---
 .../org/apache/solr/cloud/TestConfigSetsAPI.java   | 120 +++++++++++++++++++++
 3 files changed, 165 insertions(+), 14 deletions(-)

diff --git a/changelog/unreleased/SOLR-16341-fix-blank-file-zip-handling.yml 
b/changelog/unreleased/SOLR-16341-fix-blank-file-zip-handling.yml
new file mode 100644
index 00000000000..69fd1515ce7
--- /dev/null
+++ b/changelog/unreleased/SOLR-16341-fix-blank-file-zip-handling.yml
@@ -0,0 +1,8 @@
+
+title: Support blank/zero-byte files in configset zip uploads
+type: fixed
+authors:
+  - name: Eric Pugh
+links:
+  - name: SOLR-16341
+    url: https://issues.apache.org/jira/browse/SOLR-16341
diff --git 
a/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java 
b/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java
index 6728b17ef10..bb9ca94c761 100644
--- a/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java
+++ b/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java
@@ -22,11 +22,15 @@ import jakarta.inject.Inject;
 import java.io.IOException;
 import java.io.InputStream;
 import java.lang.invoke.MethodHandles;
-import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
 import java.util.ArrayList;
+import java.util.Enumeration;
 import java.util.List;
 import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
+import java.util.zip.ZipException;
+import java.util.zip.ZipFile;
 import org.apache.solr.client.api.endpoint.ConfigsetsApi;
 import org.apache.solr.client.api.model.SolrJerseyResponse;
 import org.apache.solr.client.solrj.util.SolrIdentifierValidator;
@@ -85,22 +89,41 @@ public class UploadConfigSet extends ConfigSetAPIBase
       filesToDelete = new ArrayList<>();
     }
 
-    try (ZipInputStream zis = new ZipInputStream(requestBody, 
StandardCharsets.UTF_8)) {
-      boolean hasEntry = false;
-      ZipEntry zipEntry;
-      while ((zipEntry = zis.getNextEntry()) != null) {
-        hasEntry = true;
-        String filePath = zipEntry.getName();
-        filesToDelete.remove(filePath);
-        if (!zipEntry.isDirectory()) {
-          configSetService.uploadFileToConfig(configSetName, filePath, 
zis.readAllBytes(), true);
+    // Write the request body to a temp file so we can use ZipFile, which 
reads the central
+    // directory and correctly handles entries that use the STORED method with 
an EXT (data
+    // descriptor) flag — a combination that ZipInputStream cannot process.  
This allows
+    // zero-byte files (e.g. created with `touch`) to be included in the 
uploaded configset.
+    final Path tempZip = Files.createTempFile("solr-configset-upload-", 
".zip");
+    try {
+      Files.copy(requestBody, tempZip, StandardCopyOption.REPLACE_EXISTING);
+      try (ZipFile zipFile = new ZipFile(tempZip.toFile())) {
+        boolean hasEntry = false;
+        Enumeration<? extends ZipEntry> entries = zipFile.entries();
+        while (entries.hasMoreElements()) {
+          ZipEntry zipEntry = entries.nextElement();
+          hasEntry = true;
+          String filePath = zipEntry.getName();
+          filesToDelete.remove(filePath);
+          if (!zipEntry.isDirectory()) {
+            try (InputStream entryStream = zipFile.getInputStream(zipEntry)) {
+              configSetService.uploadFileToConfig(
+                  configSetName, filePath, entryStream.readAllBytes(), true);
+            }
+          }
         }
-      }
-      if (!hasEntry) {
+        if (!hasEntry) {
+          throw new SolrException(
+              SolrException.ErrorCode.BAD_REQUEST,
+              "Either empty zipped data, or non-zipped data was uploaded. In 
order to upload a configSet, you must zip a non-empty directory to upload.");
+        }
+      } catch (ZipException e) {
         throw new SolrException(
             SolrException.ErrorCode.BAD_REQUEST,
-            "Either empty zipped data, or non-zipped data was uploaded. In 
order to upload a configSet, you must zip a non-empty directory to upload.");
+            "Failed to read the uploaded zip file: " + e.getMessage(),
+            e);
       }
+    } finally {
+      Files.deleteIfExists(tempZip);
     }
     deleteUnusedFiles(configSetService, configSetName, filesToDelete);
 
diff --git a/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java 
b/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java
index 6736af93b68..29c17139d2a 100644
--- a/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java
+++ b/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java
@@ -25,6 +25,7 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletRequestWrapper;
 import jakarta.servlet.http.HttpServletResponse;
 import java.io.ByteArrayInputStream;
+import java.io.DataOutputStream;
 import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -1002,6 +1003,32 @@ public class TestConfigSetsAPI extends SolrCloudTestCase 
{
     assertEquals(400, res);
   }
 
+  @Test
+  public void testUploadWithBlankFile() throws Exception {
+    // Uploads a zip containing a blank (0-byte) file using STORED method with 
an EXT descriptor.
+    // Java's ZipInputStream cannot read this format, but ZipFile can.
+    // Verifies the upload succeeds and the empty file is stored in the 
configset.
+    final String configSetName = "blank-file-configset";
+    final String suffix = "-suffix";
+    final Path zipFile = createTempZipWithStoredEntryAndExtDescriptor();
+    try (SolrZkClient zkClient =
+        new SolrZkClient.Builder()
+            .withUrl(cluster.getZkServer().getZkAddress())
+            .withTimeout(AbstractZkTestCase.TIMEOUT, TimeUnit.MILLISECONDS)
+            .withConnTimeOut(45000, TimeUnit.MILLISECONDS)
+            .build()) {
+      long res = uploadGivenConfigSet(zipFile, configSetName, suffix, null, 
true, false, true);
+      assertEquals("Upload of configset with blank file should succeed", 0L, 
res);
+      assertTrue(
+          "blank.txt should have been uploaded to the configset",
+          zkClient.exists("/configs/" + configSetName + suffix + 
"/blank.txt"));
+      assertArrayEquals(
+          "blank.txt in configset should be empty",
+          new byte[0],
+          zkClient.getData("/configs/" + configSetName + suffix + 
"/blank.txt", null, null));
+    }
+  }
+
   @Test
   public void testGetFile() throws Exception {
     String configSetName = "regular";
@@ -1331,6 +1358,99 @@ public class TestConfigSetsAPI extends SolrCloudTestCase 
{
     }
   }
 
+  /**
+   * Creates a zip file (in the temp directory) containing an empty file entry 
that uses the STORED
+   * compression method with the EXT descriptor flag set. Some zip tools 
produce this format for
+   * empty (0-byte) files, e.g., when using {@code touch conf/blank.txt} 
followed by {@code zip -r
+   * ...}. Java's {@link java.util.zip.ZipInputStream} cannot read this 
combination, but {@link
+   * java.util.zip.ZipFile} handles it correctly by reading from the central 
directory.
+   */
+  private Path createTempZipWithStoredEntryAndExtDescriptor() throws 
IOException {
+    final Path zipFile = createTempFile("configset-blank", "zip");
+    // Build a valid ZIP file manually with one STORED entry that has the EXT 
(data descriptor)
+    // flag set (flag bit 3 = 0x08). Java's ZipInputStream rejects this 
combination.
+    // All multi-byte fields are little-endian.
+    byte[] fileName = "blank.txt".getBytes(UTF_8);
+    int fileNameLen = fileName.length; // 9
+
+    // Offsets for computing central directory offset
+    // Local file header size: 30 + fileNameLen
+    int localHeaderSize = 30 + fileNameLen;
+    // Data descriptor size: 16 (with signature)
+    int dataDescriptorSize = 16;
+    // Central directory header size: 46 + fileNameLen
+    int centralDirHeaderSize = 46 + fileNameLen;
+    int centralDirOffset = localHeaderSize + dataDescriptorSize; // = 55
+
+    try (DataOutputStream dos = new 
DataOutputStream(Files.newOutputStream(zipFile))) {
+      // --- Local file header ---
+      dos.write(new byte[] {0x50, 0x4b, 0x03, 0x04}); // signature PK\x03\x04
+      dos.write(new byte[] {0x14, 0x00}); // version needed = 20
+      dos.write(new byte[] {0x08, 0x00}); // flag: bit 3 (data descriptor / 
EXT)
+      dos.write(new byte[] {0x00, 0x00}); // compression method: STORED
+      dos.write(new byte[] {0x00, 0x00}); // last mod time
+      dos.write(new byte[] {0x00, 0x00}); // last mod date
+      dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // CRC-32 (0, deferred 
to data descriptor)
+      dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // compressed size 
(deferred)
+      dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // uncompressed size 
(deferred)
+      dos.write(new byte[] {(byte) fileNameLen, 0x00}); // file name length
+      dos.write(new byte[] {0x00, 0x00}); // extra field length
+      dos.write(fileName); // file name "blank.txt"
+      // (no file data — the file is empty)
+
+      // --- Data descriptor (EXT record) ---
+      dos.write(new byte[] {0x50, 0x4b, 0x07, 0x08}); // signature PK\x07\x08
+      dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // CRC-32 (0 for empty 
file)
+      dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // compressed size
+      dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // uncompressed size
+
+      // --- Central directory header ---
+      dos.write(new byte[] {0x50, 0x4b, 0x01, 0x02}); // signature PK\x01\x02
+      dos.write(new byte[] {0x14, 0x00}); // version made by
+      dos.write(new byte[] {0x14, 0x00}); // version needed
+      dos.write(new byte[] {0x08, 0x00}); // flag (same as local header)
+      dos.write(new byte[] {0x00, 0x00}); // compression method: STORED
+      dos.write(new byte[] {0x00, 0x00}); // last mod time
+      dos.write(new byte[] {0x00, 0x00}); // last mod date
+      dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // CRC-32
+      dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // compressed size
+      dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // uncompressed size
+      dos.write(new byte[] {(byte) fileNameLen, 0x00}); // file name length
+      dos.write(new byte[] {0x00, 0x00}); // extra field length
+      dos.write(new byte[] {0x00, 0x00}); // file comment length
+      dos.write(new byte[] {0x00, 0x00}); // disk number start
+      dos.write(new byte[] {0x00, 0x00}); // internal file attributes
+      dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // external file 
attributes
+      dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // local header relative 
offset (= 0)
+      dos.write(fileName); // file name "blank.txt"
+
+      // --- End of central directory record ---
+      dos.write(new byte[] {0x50, 0x4b, 0x05, 0x06}); // signature PK\x05\x06
+      dos.write(new byte[] {0x00, 0x00}); // disk number
+      dos.write(new byte[] {0x00, 0x00}); // disk with start of central 
directory
+      dos.write(new byte[] {0x01, 0x00}); // entries on this disk
+      dos.write(new byte[] {0x01, 0x00}); // total entries
+      // size of central directory
+      dos.write(
+          new byte[] {
+            (byte) (centralDirHeaderSize & 0xFF),
+            (byte) ((centralDirHeaderSize >> 8) & 0xFF),
+            (byte) ((centralDirHeaderSize >> 16) & 0xFF),
+            (byte) ((centralDirHeaderSize >> 24) & 0xFF)
+          });
+      // offset of central directory
+      dos.write(
+          new byte[] {
+            (byte) (centralDirOffset & 0xFF),
+            (byte) ((centralDirOffset >> 8) & 0xFF),
+            (byte) ((centralDirOffset >> 16) & 0xFF),
+            (byte) ((centralDirOffset >> 24) & 0xFF)
+          });
+      dos.write(new byte[] {0x00, 0x00}); // comment length
+    }
+    return zipFile;
+  }
+
   private static void zipWithForbiddenContent(Path directory, Path zipfile) 
throws IOException {
     OutputStream out = Files.newOutputStream(zipfile);
     assertTrue(Files.isDirectory(directory));

Reply via email to