Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +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.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.common.SolrException;
Expand Down Expand Up @@ -77,22 +81,41 @@ public SolrJerseyResponse uploadConfigSet(
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);

Expand Down
120 changes: 120 additions & 0 deletions solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1093,6 +1094,32 @@ 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.
// 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));
}
}

private static String getSecurityJson() {
return "{\n"
+ " 'authentication':{\n"
Expand Down Expand Up @@ -1357,6 +1384,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. 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));
Expand Down
Loading