Skip to content
Draft
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
14 changes: 12 additions & 2 deletions sdk/src/main/java/io/opentdf/platform/sdk/ZipReader.java
Original file line number Diff line number Diff line change
Expand Up @@ -105,19 +105,25 @@ public CentralDirectoryRecord(long numEntries, long offsetToStart) {
CentralDirectoryRecord readEndOfCentralDirectory() throws IOException {
long eoCDRStart = zipChannel.size() - END_OF_CENTRAL_DIRECTORY_SIZE; // 22 is the minimum size of the EOCDR

boolean found = false;
while (eoCDRStart >= 0) {
zipChannel.position(eoCDRStart);
Integer signature = readInteger();
if (signature == null || signature == END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
// a short read means there aren't four bytes here to compare against, which is not
// the same thing as having found the signature. treating it as a match let a
// truncated archive fall out of this loop and parse whatever followed as an end of
// central directory record
if (signature != null && signature == END_OF_CENTRAL_DIRECTORY_SIGNATURE) {
if (logger.isDebugEnabled()) {
logger.debug("Found end of central directory signature at {}", zipChannel.position() - Integer.BYTES);
}
found = true;
break;
}
eoCDRStart--;
}

if (eoCDRStart < 0) {
if (!found) {
throw new InvalidZipException("Didn't find the end of central directory");
}

Expand All @@ -141,6 +147,10 @@ CentralDirectoryRecord readEndOfCentralDirectory() throws IOException {
}

long zip64CentralDirectoryLocatorStart = zipChannel.size() - (ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIZE + END_OF_CENTRAL_DIRECTORY_SIZE + commentLength);
if (zip64CentralDirectoryLocatorStart < 0) {
throw new InvalidZipException(
"Archive is too small to hold the zip64 end of central directory locator it claims to have");
}
zipChannel.position(zip64CentralDirectoryLocatorStart);
return extractZIP64CentralDirectoryInfo();
}
Expand Down
54 changes: 41 additions & 13 deletions sdk/src/main/java/io/opentdf/platform/sdk/ZipWriter.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ public class ZipWriter {
* {@link ZipReader}.
*/
static final long MAX_NON_ZIP64_VALUE = Integer.MAX_VALUE;

/**
* The largest entry count we will write into the 2-byte end of central directory field.
* {@code 0xFFFF} itself is the sentinel that sends the real count to the zip64 end of central
* directory record, so it cannot be used as a literal value.
*/
private static final long MAX_NON_ZIP64_ENTRY_COUNT = 0xFFFE;

/** The largest name we can describe in the 2-byte filename length field. */
private static final int MAX_FILENAME_LENGTH = 0xFFFF;

private static final long ZIP_64_END_OF_CD_RECORD_SIZE = 56;

private static final int ZIP_64_GLOBAL_EXTENDED_INFO_EXTRA_FIELD_SIZE = 28;
Expand Down Expand Up @@ -81,16 +92,30 @@ static void checkFitsInCentralDirectory(String name, long offset, long size) {
}
}

/**
* Encodes an entry name the way it is written to the archive. Zip filename lengths are
* counted in bytes, so anything derived from {@link String#length()} — which counts UTF-16
* code units — desyncs the central directory for a non-ASCII name.
*/
private static byte[] encodeFilename(String name) {
var bytes = name.getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_FILENAME_LENGTH) {
throw new SDKException("zip entry name is " + bytes.length
+ " bytes when encoded as UTF-8, which does not fit in the "
+ MAX_FILENAME_LENGTH + " byte filename length field");
}
return bytes;
}

public OutputStream stream(String name) throws IOException {
var startPosition = out.position;
long fileTime, fileDate;
fileTime = fileDate = getTimeDateUnMSDosFormat();

var nameBytes = name.getBytes(StandardCharsets.UTF_8);
var nameBytes = encodeFilename(name);
LocalFileHeader localFileHeader = new LocalFileHeader();
localFileHeader.lastModifiedTime = (int) fileTime;
localFileHeader.lastModifiedDate = (int) fileDate;
localFileHeader.filenameLength = (short) nameBytes.length;
localFileHeader.crc32 = 0;
localFileHeader.generalPurposeBitFlag = (1 << 3) | (1 << 11); // we are using the data descriptor and we are using UTF-8
localFileHeader.compressedSize = ZIP_64_MAGIC_VAL;
Expand Down Expand Up @@ -170,12 +195,12 @@ private static void writeCentralDirectoryHeader(FileInfo fileInfo, OutputStream
checkFitsInCentralDirectory(fileInfo.filename, fileInfo.offset, fileInfo.size);
}

var nameBytes = encodeFilename(fileInfo.filename);
CDFileHeader cdFileHeader = new CDFileHeader();
cdFileHeader.generalPurposeBitFlag = fileInfo.flag;
cdFileHeader.lastModifiedTime = fileInfo.fileTime;
cdFileHeader.lastModifiedDate = fileInfo.fileDate;
cdFileHeader.crc32 = (int) fileInfo.crc;
cdFileHeader.filenameLength = (short) fileInfo.filename.length();
cdFileHeader.extraFieldLength = 0;
cdFileHeader.compressedSize = (int) fileInfo.size;
cdFileHeader.uncompressedSize = (int) fileInfo.size;
Expand All @@ -188,7 +213,7 @@ private static void writeCentralDirectoryHeader(FileInfo fileInfo, OutputStream
cdFileHeader.extraFieldLength = ZIP_64_GLOBAL_EXTENDED_INFO_EXTRA_FIELD_SIZE;
}

cdFileHeader.write(out, fileInfo.filename.getBytes(StandardCharsets.UTF_8));
cdFileHeader.write(out, nameBytes);

if (fileInfo.isZip64) {
Zip64GlobalExtendedInfoExtraField zip64ExtendedInfoExtraField = new Zip64GlobalExtendedInfoExtraField();
Expand All @@ -208,18 +233,17 @@ private FileInfo writeByteArray(String name, byte[] data, CountingOutputStream o
crc.update(data);
var crcValue = crc.getValue();

var nameBytes = name.getBytes(StandardCharsets.UTF_8);
var nameBytes = encodeFilename(name);
LocalFileHeader localFileHeader = new LocalFileHeader();
localFileHeader.lastModifiedTime = (int) fileTime;
localFileHeader.lastModifiedDate = (int) fileDate;
localFileHeader.filenameLength = (short) nameBytes.length;
localFileHeader.generalPurposeBitFlag = 0;
localFileHeader.crc32 = (int) crcValue;
localFileHeader.compressedSize = data.length;
localFileHeader.uncompressedSize = data.length;
localFileHeader.extraFieldLength = 0;

localFileHeader.write(out, name.getBytes(StandardCharsets.UTF_8));
localFileHeader.write(out, nameBytes);

out.write(data);

Expand All @@ -238,10 +262,14 @@ private FileInfo writeByteArray(String name, byte[] data, CountingOutputStream o


private void writeEndOfCentralDirectory(boolean hasZip64Entry, long numEntries, long startOfCentralDirectory, long sizeOfCentralDirectory, CountingOutputStream out) throws IOException {
// each of these corresponds to a field in the end of central directory record that has to
// carry a sentinel — and send its real value to the zip64 record — once the value stops
// fitting. the entry count is 2 bytes; the offset and size are 4 bytes each, and are held
// to the same 2 GiB ceiling as the per-entry fields so that readers which widen them with
// a signed read never see a negative value. see MAX_NON_ZIP64_VALUE.
var isZip64 = hasZip64Entry
|| (numEntries & ~0xFF) != 0
|| (startOfCentralDirectory & ~0xFFFF) != 0
|| (sizeOfCentralDirectory & ~0xFFFF) != 0;
|| numEntries > MAX_NON_ZIP64_ENTRY_COUNT
|| needsZip64(startOfCentralDirectory, sizeOfCentralDirectory);

if (isZip64) {
var endPosition = out.position;
Expand Down Expand Up @@ -322,7 +350,6 @@ private static class LocalFileHeader {
int compressedSize;
int uncompressedSize;

short filenameLength;
short extraFieldLength = 0;

void write(OutputStream out, byte[] filename) throws IOException {
Expand All @@ -337,7 +364,8 @@ void write(OutputStream out, byte[] filename) throws IOException {
buffer.putInt(crc32);
buffer.putInt(compressedSize);
buffer.putInt(uncompressedSize);
buffer.putShort(filenameLength);
// the length of the encoded bytes, never String.length()
buffer.putShort((short) filename.length);
buffer.putShort(extraFieldLength);
buffer.put(filename);

Expand Down Expand Up @@ -376,7 +404,6 @@ private static class CDFileHeader {
int crc32;
int compressedSize;
int uncompressedSize;
short filenameLength;
short extraFieldLength;
final short fileCommentLength = 0;
final short diskNumberStart = 0;
Expand All @@ -397,6 +424,7 @@ void write(OutputStream out, byte[] filename) throws IOException {
buffer.putInt(crc32);
buffer.putInt(compressedSize);
buffer.putInt(uncompressedSize);
// the length of the encoded bytes, never String.length()
buffer.putShort((short) filename.length);
buffer.putShort(extraFieldLength);
buffer.putShort(fileCommentLength);
Expand Down
49 changes: 49 additions & 0 deletions sdk/src/test/java/io/opentdf/platform/sdk/ZipReaderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@
import java.nio.ByteOrder;
import java.nio.channels.SeekableByteChannel;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

public class ZipReaderTest {

Expand Down Expand Up @@ -258,6 +260,53 @@
}
}

/**
* Scanning for the end of central directory used to treat a short read the same as finding
* the signature, so a truncated archive fell out of the scan and parsed whatever followed as
* the record. Truncation has to surface as "this is not a zip", not as a confusing failure
* somewhere downstream.
*/
@Test
public void testTruncatedArchiveIsRejected() throws IOException {
var archive = zip64Archive();

// the trailing end of central directory record and its locator are gone
assertThatThrownBy(() -> readArchive(

Check warning on line 274 in sdk/src/test/java/io/opentdf/platform/sdk/ZipReaderTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=opentdf_java-sdk&issues=AaBoRc8AAWb5K6dsgAuq&open=AaBoRc8AAWb5K6dsgAuq&pullRequest=396
Arrays.copyOf(archive, archive.length - (EOCD_SIZE + ZIP64_EOCD_LOCATOR_SIZE))))
.isInstanceOf(InvalidZipException.class);

// only the last few bytes of the record are gone
assertThatThrownBy(() -> readArchive(Arrays.copyOf(archive, archive.length - 4)))

Check warning on line 279 in sdk/src/test/java/io/opentdf/platform/sdk/ZipReaderTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=opentdf_java-sdk&issues=AaBoRc8AAWb5K6dsgAur&open=AaBoRc8AAWb5K6dsgAur&pullRequest=396
.isInstanceOf(InvalidZipException.class);

// cut down to less than a single end of central directory record
assertThatThrownBy(() -> readArchive(Arrays.copyOf(archive, EOCD_SIZE - 1)))

Check warning on line 283 in sdk/src/test/java/io/opentdf/platform/sdk/ZipReaderTest.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=opentdf_java-sdk&issues=AaBoRc8AAWb5K6dsgAus&open=AaBoRc8AAWb5K6dsgAus&pullRequest=396
.isInstanceOf(InvalidZipException.class);

assertThatThrownBy(() -> readArchive(new byte[0]))
.isInstanceOf(InvalidZipException.class);
}

/**
* The end of central directory record claims a zip64 locator that the archive is too short to
* hold. Reaching for it has to be a zip error rather than an out of range seek.
*/
@Test
public void testArchiveTooShortForTheZip64LocatorIsRejected() throws IOException {
var archive = zip64Archive();
// drop everything before the trailing records, leaving the end of central directory (which
// still carries its sentinels) with nothing in front of it
var truncated = Arrays.copyOfRange(archive, archive.length - EOCD_SIZE, archive.length);

assertThatThrownBy(() -> readArchive(truncated)).isInstanceOf(InvalidZipException.class);
}

private static void readArchive(byte[] archive) throws IOException {
try (var channel = new SeekableInMemoryByteChannel(archive)) {
new ZipReader(channel);
}
}

private static String readEntry(ZipReader reader, String name) throws IOException {
var entry = reader.getEntries().stream()
.filter(e -> e.getName().equals(name))
Expand Down
Loading
Loading