From 973d6f0b11930d448ea37364634a640f69e0a4dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:01:37 +0000 Subject: [PATCH 1/5] Initial plan From ed7a48f8797aeb291c99b74ae987fe01fc3a1bc2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:20:55 +0000 Subject: [PATCH 2/5] Fix ZipException when uploading configset zip with blank files Agent-Logs-Url: https://github.com/epugh/solr/sessions/eb7b8ea1-822d-48c5-a04f-1434ee9b17ea Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../handler/configsets/UploadConfigSet.java | 22 +++- .../apache/solr/cloud/TestConfigSetsAPI.java | 104 ++++++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) 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 617d5c8af22..c2c3e641f15 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 @@ -26,6 +26,7 @@ import java.util.ArrayList; import java.util.List; import java.util.zip.ZipEntry; +import java.util.zip.ZipException; import java.util.zip.ZipInputStream; import org.apache.solr.client.api.endpoint.ConfigsetsApi; import org.apache.solr.client.api.model.SolrJerseyResponse; @@ -80,13 +81,22 @@ public SolrJerseyResponse uploadConfigSet( 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); + try { + while ((zipEntry = zis.getNextEntry()) != null) { + hasEntry = true; + String filePath = zipEntry.getName(); + filesToDelete.remove(filePath); + if (!zipEntry.isDirectory()) { + configSetService.uploadFileToConfig(configSetName, filePath, zis.readAllBytes(), true); + } } + } catch (ZipException e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Failed to read the uploaded zip file. The file may be malformed or use an unsupported format. " + + "Please recreate the zip file using standard compression tools: " + + e.getMessage(), + e); } if (!hasEntry) { throw new SolrException( 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 6bc03e5ef31..9d50f553afb 100644 --- a/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java +++ b/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java @@ -26,6 +26,7 @@ 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; @@ -1093,6 +1094,16 @@ public void testUploadWithForbiddenContent() throws Exception { 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, + // which Java's ZipInputStream cannot read. Verifies a 400 error is returned instead of a 500. + final Path zipFile = createTempZipWithStoredEntryAndExtDescriptor(); + long res = + uploadGivenConfigSet(zipFile, "blank-file-configset", "-suffix", null, true, false, true); + assertEquals(400, res); + } + private static String getSecurityJson() { return "{\n" + " 'authentication':{\n" @@ -1357,6 +1368,99 @@ private Path createTempZipFileWithForbiddenContent(String resourcePath) { } } + /** + * 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. Java's {@link + * java.util.zip.ZipInputStream} cannot read STORED entries with data descriptors and throws a + * {@link java.util.zip.ZipException}. 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 ...}. + */ + 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)); From c950fb7bed467951be1bbdcd38e376a373beb6d7 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sun, 29 Mar 2026 15:52:22 -0400 Subject: [PATCH 3/5] track change --- .../unreleased/SOLR-16341-fix-blank-file-zip-handling.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog/unreleased/SOLR-16341-fix-blank-file-zip-handling.yml 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..99cc14abe22 --- /dev/null +++ b/changelog/unreleased/SOLR-16341-fix-blank-file-zip-handling.yml @@ -0,0 +1,8 @@ + +title: Fix blank file in configset zip upload handling by providing clearer error +type: fixed +authors: + - name: Eric Pugh +links: + - name: SOLR-16341 + url: https://issues.apache.org/jira/browse/SOLR-16341 From 6f9de03f2536529c86cb683f47449addb69eccc8 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Tue, 31 Mar 2026 07:50:18 -0400 Subject: [PATCH 4/5] Update solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../solr/handler/configsets/UploadConfigSet.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 c2c3e641f15..70c461aafbc 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 @@ -91,12 +91,15 @@ public SolrJerseyResponse uploadConfigSet( } } } catch (ZipException e) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - "Failed to read the uploaded zip file. The file may be malformed or use an unsupported format. " - + "Please recreate the zip file using standard compression tools: " - + e.getMessage(), - e); + StringBuilder msg = + new StringBuilder( + "Failed to read the uploaded zip file. The file may be malformed or use an unsupported ZIP feature (for example, a STORED entry with a data descriptor / EXT flag). " + + "Try recreating the zip using DEFLATED compression or avoid zero-byte STORED entries with data descriptors."); + String underlying = e.getMessage(); + if (underlying != null && !underlying.isEmpty()) { + msg.append(" Underlying error: ").append(underlying); + } + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, msg.toString(), e); } if (!hasEntry) { throw new SolrException( From 0daae023915183ff4b78dc8afe392c3956f444ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:06:46 +0000 Subject: [PATCH 5/5] Handle blank/zero-byte files in configset zip by switching to ZipFile Agent-Logs-Url: https://github.com/epugh/solr/sessions/6056cf2a-a940-40bf-91c2-2e9b92afd7ce Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- ...SOLR-16341-fix-blank-file-zip-handling.yml | 2 +- .../handler/configsets/UploadConfigSet.java | 50 +++++++++++-------- .../apache/solr/cloud/TestConfigSetsAPI.java | 34 +++++++++---- 3 files changed, 56 insertions(+), 30 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 index 99cc14abe22..69fd1515ce7 100644 --- a/changelog/unreleased/SOLR-16341-fix-blank-file-zip-handling.yml +++ b/changelog/unreleased/SOLR-16341-fix-blank-file-zip-handling.yml @@ -1,5 +1,5 @@ -title: Fix blank file in configset zip upload handling by providing clearer error +title: Support blank/zero-byte files in configset zip uploads type: fixed authors: - name: Eric Pugh 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 70c461aafbc..0271143ca03 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,12 +22,15 @@ 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.ZipException; -import java.util.zip.ZipInputStream; +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.common.SolrException; @@ -78,34 +81,41 @@ public SolrJerseyResponse uploadConfigSet( filesToDelete = new ArrayList<>(); } - try (ZipInputStream zis = new ZipInputStream(requestBody, StandardCharsets.UTF_8)) { - boolean hasEntry = false; - ZipEntry zipEntry; - try { - while ((zipEntry = zis.getNextEntry()) != null) { + // 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 entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry zipEntry = entries.nextElement(); hasEntry = true; String filePath = zipEntry.getName(); filesToDelete.remove(filePath); if (!zipEntry.isDirectory()) { - configSetService.uploadFileToConfig(configSetName, filePath, zis.readAllBytes(), true); + try (InputStream entryStream = zipFile.getInputStream(zipEntry)) { + configSetService.uploadFileToConfig( + configSetName, filePath, entryStream.readAllBytes(), true); + } } } - } catch (ZipException e) { - StringBuilder msg = - new StringBuilder( - "Failed to read the uploaded zip file. The file may be malformed or use an unsupported ZIP feature (for example, a STORED entry with a data descriptor / EXT flag). " - + "Try recreating the zip using DEFLATED compression or avoid zero-byte STORED entries with data descriptors."); - String underlying = e.getMessage(); - if (underlying != null && !underlying.isEmpty()) { - msg.append(" Underlying error: ").append(underlying); + 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."); } - throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, msg.toString(), e); - } - if (!hasEntry) { + } 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 9d50f553afb..24054bdc448 100644 --- a/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java +++ b/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java @@ -1096,12 +1096,28 @@ public void testUploadWithForbiddenContent() throws Exception { @Test public void testUploadWithBlankFile() throws Exception { - // Uploads a zip containing a blank (0-byte) file using STORED method with an EXT descriptor, - // which Java's ZipInputStream cannot read. Verifies a 400 error is returned instead of a 500. + // 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(); - long res = - uploadGivenConfigSet(zipFile, "blank-file-configset", "-suffix", null, true, false, true); - assertEquals(400, res); + 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)); + } } private static String getSecurityJson() { @@ -1370,10 +1386,10 @@ private Path createTempZipFileWithForbiddenContent(String resourcePath) { /** * 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. Java's {@link - * java.util.zip.ZipInputStream} cannot read STORED entries with data descriptors and throws a - * {@link java.util.zip.ZipException}. 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 ...}. + * 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");