Skip to content

Commit 33af791

Browse files
epughCopilotCopilot
authored
SOLR-16341: fix blank file zip handling (#4249)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: epugh <22395+epugh@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent f9f5abf commit 33af791

3 files changed

Lines changed: 165 additions & 14 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
2+
title: Support blank/zero-byte files in configset zip uploads
3+
type: fixed
4+
authors:
5+
- name: Eric Pugh
6+
links:
7+
- name: SOLR-16341
8+
url: https://issues.apache.org/jira/browse/SOLR-16341

solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,15 @@
2222
import java.io.IOException;
2323
import java.io.InputStream;
2424
import java.lang.invoke.MethodHandles;
25-
import java.nio.charset.StandardCharsets;
25+
import java.nio.file.Files;
26+
import java.nio.file.Path;
27+
import java.nio.file.StandardCopyOption;
2628
import java.util.ArrayList;
29+
import java.util.Enumeration;
2730
import java.util.List;
2831
import java.util.zip.ZipEntry;
29-
import java.util.zip.ZipInputStream;
32+
import java.util.zip.ZipException;
33+
import java.util.zip.ZipFile;
3034
import org.apache.solr.client.api.endpoint.ConfigsetsApi;
3135
import org.apache.solr.client.api.model.SolrJerseyResponse;
3236
import org.apache.solr.client.solrj.util.SolrIdentifierValidator;
@@ -85,22 +89,41 @@ public SolrJerseyResponse uploadConfigSet(
8589
filesToDelete = new ArrayList<>();
8690
}
8791

88-
try (ZipInputStream zis = new ZipInputStream(requestBody, StandardCharsets.UTF_8)) {
89-
boolean hasEntry = false;
90-
ZipEntry zipEntry;
91-
while ((zipEntry = zis.getNextEntry()) != null) {
92-
hasEntry = true;
93-
String filePath = zipEntry.getName();
94-
filesToDelete.remove(filePath);
95-
if (!zipEntry.isDirectory()) {
96-
configSetService.uploadFileToConfig(configSetName, filePath, zis.readAllBytes(), true);
92+
// Write the request body to a temp file so we can use ZipFile, which reads the central
93+
// directory and correctly handles entries that use the STORED method with an EXT (data
94+
// descriptor) flag — a combination that ZipInputStream cannot process. This allows
95+
// zero-byte files (e.g. created with `touch`) to be included in the uploaded configset.
96+
final Path tempZip = Files.createTempFile("solr-configset-upload-", ".zip");
97+
try {
98+
Files.copy(requestBody, tempZip, StandardCopyOption.REPLACE_EXISTING);
99+
try (ZipFile zipFile = new ZipFile(tempZip.toFile())) {
100+
boolean hasEntry = false;
101+
Enumeration<? extends ZipEntry> entries = zipFile.entries();
102+
while (entries.hasMoreElements()) {
103+
ZipEntry zipEntry = entries.nextElement();
104+
hasEntry = true;
105+
String filePath = zipEntry.getName();
106+
filesToDelete.remove(filePath);
107+
if (!zipEntry.isDirectory()) {
108+
try (InputStream entryStream = zipFile.getInputStream(zipEntry)) {
109+
configSetService.uploadFileToConfig(
110+
configSetName, filePath, entryStream.readAllBytes(), true);
111+
}
112+
}
97113
}
98-
}
99-
if (!hasEntry) {
114+
if (!hasEntry) {
115+
throw new SolrException(
116+
SolrException.ErrorCode.BAD_REQUEST,
117+
"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.");
118+
}
119+
} catch (ZipException e) {
100120
throw new SolrException(
101121
SolrException.ErrorCode.BAD_REQUEST,
102-
"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.");
122+
"Failed to read the uploaded zip file: " + e.getMessage(),
123+
e);
103124
}
125+
} finally {
126+
Files.deleteIfExists(tempZip);
104127
}
105128
deleteUnusedFiles(configSetService, configSetName, filesToDelete);
106129

solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import jakarta.servlet.http.HttpServletRequestWrapper;
2626
import jakarta.servlet.http.HttpServletResponse;
2727
import java.io.ByteArrayInputStream;
28+
import java.io.DataOutputStream;
2829
import java.io.FileInputStream;
2930
import java.io.IOException;
3031
import java.io.InputStream;
@@ -1002,6 +1003,32 @@ public void testUploadWithForbiddenContent() throws Exception {
10021003
assertEquals(400, res);
10031004
}
10041005

1006+
@Test
1007+
public void testUploadWithBlankFile() throws Exception {
1008+
// Uploads a zip containing a blank (0-byte) file using STORED method with an EXT descriptor.
1009+
// Java's ZipInputStream cannot read this format, but ZipFile can.
1010+
// Verifies the upload succeeds and the empty file is stored in the configset.
1011+
final String configSetName = "blank-file-configset";
1012+
final String suffix = "-suffix";
1013+
final Path zipFile = createTempZipWithStoredEntryAndExtDescriptor();
1014+
try (SolrZkClient zkClient =
1015+
new SolrZkClient.Builder()
1016+
.withUrl(cluster.getZkServer().getZkAddress())
1017+
.withTimeout(AbstractZkTestCase.TIMEOUT, TimeUnit.MILLISECONDS)
1018+
.withConnTimeOut(45000, TimeUnit.MILLISECONDS)
1019+
.build()) {
1020+
long res = uploadGivenConfigSet(zipFile, configSetName, suffix, null, true, false, true);
1021+
assertEquals("Upload of configset with blank file should succeed", 0L, res);
1022+
assertTrue(
1023+
"blank.txt should have been uploaded to the configset",
1024+
zkClient.exists("/configs/" + configSetName + suffix + "/blank.txt"));
1025+
assertArrayEquals(
1026+
"blank.txt in configset should be empty",
1027+
new byte[0],
1028+
zkClient.getData("/configs/" + configSetName + suffix + "/blank.txt", null, null));
1029+
}
1030+
}
1031+
10051032
@Test
10061033
public void testGetFile() throws Exception {
10071034
String configSetName = "regular";
@@ -1331,6 +1358,99 @@ private Path createTempZipFileWithForbiddenContent(String resourcePath) {
13311358
}
13321359
}
13331360

1361+
/**
1362+
* Creates a zip file (in the temp directory) containing an empty file entry that uses the STORED
1363+
* compression method with the EXT descriptor flag set. Some zip tools produce this format for
1364+
* empty (0-byte) files, e.g., when using {@code touch conf/blank.txt} followed by {@code zip -r
1365+
* ...}. Java's {@link java.util.zip.ZipInputStream} cannot read this combination, but {@link
1366+
* java.util.zip.ZipFile} handles it correctly by reading from the central directory.
1367+
*/
1368+
private Path createTempZipWithStoredEntryAndExtDescriptor() throws IOException {
1369+
final Path zipFile = createTempFile("configset-blank", "zip");
1370+
// Build a valid ZIP file manually with one STORED entry that has the EXT (data descriptor)
1371+
// flag set (flag bit 3 = 0x08). Java's ZipInputStream rejects this combination.
1372+
// All multi-byte fields are little-endian.
1373+
byte[] fileName = "blank.txt".getBytes(UTF_8);
1374+
int fileNameLen = fileName.length; // 9
1375+
1376+
// Offsets for computing central directory offset
1377+
// Local file header size: 30 + fileNameLen
1378+
int localHeaderSize = 30 + fileNameLen;
1379+
// Data descriptor size: 16 (with signature)
1380+
int dataDescriptorSize = 16;
1381+
// Central directory header size: 46 + fileNameLen
1382+
int centralDirHeaderSize = 46 + fileNameLen;
1383+
int centralDirOffset = localHeaderSize + dataDescriptorSize; // = 55
1384+
1385+
try (DataOutputStream dos = new DataOutputStream(Files.newOutputStream(zipFile))) {
1386+
// --- Local file header ---
1387+
dos.write(new byte[] {0x50, 0x4b, 0x03, 0x04}); // signature PK\x03\x04
1388+
dos.write(new byte[] {0x14, 0x00}); // version needed = 20
1389+
dos.write(new byte[] {0x08, 0x00}); // flag: bit 3 (data descriptor / EXT)
1390+
dos.write(new byte[] {0x00, 0x00}); // compression method: STORED
1391+
dos.write(new byte[] {0x00, 0x00}); // last mod time
1392+
dos.write(new byte[] {0x00, 0x00}); // last mod date
1393+
dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // CRC-32 (0, deferred to data descriptor)
1394+
dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // compressed size (deferred)
1395+
dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // uncompressed size (deferred)
1396+
dos.write(new byte[] {(byte) fileNameLen, 0x00}); // file name length
1397+
dos.write(new byte[] {0x00, 0x00}); // extra field length
1398+
dos.write(fileName); // file name "blank.txt"
1399+
// (no file data — the file is empty)
1400+
1401+
// --- Data descriptor (EXT record) ---
1402+
dos.write(new byte[] {0x50, 0x4b, 0x07, 0x08}); // signature PK\x07\x08
1403+
dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // CRC-32 (0 for empty file)
1404+
dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // compressed size
1405+
dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // uncompressed size
1406+
1407+
// --- Central directory header ---
1408+
dos.write(new byte[] {0x50, 0x4b, 0x01, 0x02}); // signature PK\x01\x02
1409+
dos.write(new byte[] {0x14, 0x00}); // version made by
1410+
dos.write(new byte[] {0x14, 0x00}); // version needed
1411+
dos.write(new byte[] {0x08, 0x00}); // flag (same as local header)
1412+
dos.write(new byte[] {0x00, 0x00}); // compression method: STORED
1413+
dos.write(new byte[] {0x00, 0x00}); // last mod time
1414+
dos.write(new byte[] {0x00, 0x00}); // last mod date
1415+
dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // CRC-32
1416+
dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // compressed size
1417+
dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // uncompressed size
1418+
dos.write(new byte[] {(byte) fileNameLen, 0x00}); // file name length
1419+
dos.write(new byte[] {0x00, 0x00}); // extra field length
1420+
dos.write(new byte[] {0x00, 0x00}); // file comment length
1421+
dos.write(new byte[] {0x00, 0x00}); // disk number start
1422+
dos.write(new byte[] {0x00, 0x00}); // internal file attributes
1423+
dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // external file attributes
1424+
dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // local header relative offset (= 0)
1425+
dos.write(fileName); // file name "blank.txt"
1426+
1427+
// --- End of central directory record ---
1428+
dos.write(new byte[] {0x50, 0x4b, 0x05, 0x06}); // signature PK\x05\x06
1429+
dos.write(new byte[] {0x00, 0x00}); // disk number
1430+
dos.write(new byte[] {0x00, 0x00}); // disk with start of central directory
1431+
dos.write(new byte[] {0x01, 0x00}); // entries on this disk
1432+
dos.write(new byte[] {0x01, 0x00}); // total entries
1433+
// size of central directory
1434+
dos.write(
1435+
new byte[] {
1436+
(byte) (centralDirHeaderSize & 0xFF),
1437+
(byte) ((centralDirHeaderSize >> 8) & 0xFF),
1438+
(byte) ((centralDirHeaderSize >> 16) & 0xFF),
1439+
(byte) ((centralDirHeaderSize >> 24) & 0xFF)
1440+
});
1441+
// offset of central directory
1442+
dos.write(
1443+
new byte[] {
1444+
(byte) (centralDirOffset & 0xFF),
1445+
(byte) ((centralDirOffset >> 8) & 0xFF),
1446+
(byte) ((centralDirOffset >> 16) & 0xFF),
1447+
(byte) ((centralDirOffset >> 24) & 0xFF)
1448+
});
1449+
dos.write(new byte[] {0x00, 0x00}); // comment length
1450+
}
1451+
return zipFile;
1452+
}
1453+
13341454
private static void zipWithForbiddenContent(Path directory, Path zipfile) throws IOException {
13351455
OutputStream out = Files.newOutputStream(zipfile);
13361456
assertTrue(Files.isDirectory(directory));

0 commit comments

Comments
 (0)