This is an automated email from the ASF dual-hosted git repository. ggregory pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/commons-compress.git
The following commit(s) were added to refs/heads/master by this push: new 07323f25f COMPRESS-653: Fix split archive updating incorrect file (#455) 07323f25f is described below commit 07323f25f7236ffb7e53aceadc1abef8897ff35c Author: Zbynek Vyskovsky <kvr...@gmail.com> AuthorDate: Mon Jan 15 07:52:17 2024 -0800 COMPRESS-653: Fix split archive updating incorrect file (#455) * COMPRESS-653: Fix split archive updating incorrect file * COMPRESS-653: Use ZipOpenOptions builder to specify increasing amount of Zip options * Don't initialize instance variable to its default * Use final --------- Co-authored-by: Gary Gregory <garydgreg...@users.noreply.github.com> --- .../zip/FileRandomAccessOutputStream.java | 79 +++++++++ .../archivers/zip/RandomAccessOutputStream.java | 70 ++++++++ .../SeekableChannelRandomAccessOutputStream.java | 62 +++++++ .../archivers/zip/ZipArchiveOutputStream.java | 95 ++++------- .../commons/compress/archivers/zip/ZipFile.java | 153 +++++++++++++---- .../commons/compress/archivers/zip/ZipIoUtil.java | 78 +++++++++ .../archivers/zip/ZipSplitOutputStream.java | 93 ++++++++++- .../zip/ZipSplitReadOnlySeekableByteChannel.java | 18 +- .../apache/commons/compress/archivers/ZipTest.java | 158 ++++++++++++++++++ .../zip/FileRandomAccessOutputStreamTest.java | 150 +++++++++++++++++ .../zip/RandomAccessOutputStreamTest.java | 59 +++++++ ...eekableChannelRandomAccessOutputStreamTest.java | 168 +++++++++++++++++++ .../archivers/zip/ZipArchiveOutputStreamTest.java | 44 +++++ .../compress/archivers/zip/ZipIoUtilTest.java | 184 +++++++++++++++++++++ .../ZipSplitReadOnlySeekableByteChannelTest.java | 2 +- 15 files changed, 1312 insertions(+), 101 deletions(-) diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/FileRandomAccessOutputStream.java b/src/main/java/org/apache/commons/compress/archivers/zip/FileRandomAccessOutputStream.java new file mode 100644 index 000000000..8a431c4b5 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/zip/FileRandomAccessOutputStream.java @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.compress.archivers.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + + +/** + * {@link RandomAccessOutputStream} implementation based on Path file. + */ +class FileRandomAccessOutputStream extends RandomAccessOutputStream { + + private final FileChannel channel; + + private long position; + + FileRandomAccessOutputStream(final Path file) throws IOException { + this(file, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + } + + FileRandomAccessOutputStream(final Path file, OpenOption... options) throws IOException { + this(FileChannel.open(file, options)); + } + + FileRandomAccessOutputStream(final FileChannel channel) throws IOException { + this.channel = channel; + } + + FileChannel channel() { + return channel; + } + + @Override + public synchronized void write(final byte[] b, final int off, final int len) throws IOException { + ZipIoUtil.writeFully(this.channel, ByteBuffer.wrap(b, off, len)); + position += len; + } + + @Override + public synchronized long position() { + return position; + } + + @Override + public void writeFullyAt(final byte[] b, final int off, final int len, final long atPosition) throws IOException { + ByteBuffer buf = ByteBuffer.wrap(b, off, len); + for (long currentPos = atPosition; buf.hasRemaining(); ) { + int written = this.channel.write(buf, currentPos); + if (written <= 0) { + throw new IOException("Failed to fully write to file: written=" + written); + } + currentPos += written; + } + } + + @Override + public void close() throws IOException { + channel.close(); + } +} diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/RandomAccessOutputStream.java b/src/main/java/org/apache/commons/compress/archivers/zip/RandomAccessOutputStream.java new file mode 100644 index 000000000..87bfd5913 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/zip/RandomAccessOutputStream.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.compress.archivers.zip; + +import java.io.IOException; +import java.io.OutputStream; + + +/** + * Abstraction over OutputStream which also allows random access writes. + */ +abstract class RandomAccessOutputStream extends OutputStream { + + /** + * Provides current position in output. + * + * @return + * current position. + */ + public abstract long position() throws IOException; + + /** + * Writes given data to specific position. + * + * @param position + * position in the stream + * @param b + * data to write + * @throws IOException + * when write fails. + */ + public void writeFullyAt(final byte[] b, final long position) throws IOException { + writeFullyAt(b, 0, b.length, position); + } + + /** + * Writes given data to specific position. + * + * @param position + * position in the stream + * @param b + * data to write + * @param off + * offset of the start of data in param b + * @param len + * the length of data to write + * @throws IOException + * when write fails. + */ + abstract void writeFullyAt(byte[] b, int off, int len, long position) throws IOException; + + @Override + public void write(final int b) throws IOException { + write(new byte[]{ (byte) b }); + } +} diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/SeekableChannelRandomAccessOutputStream.java b/src/main/java/org/apache/commons/compress/archivers/zip/SeekableChannelRandomAccessOutputStream.java new file mode 100644 index 000000000..accad6879 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/zip/SeekableChannelRandomAccessOutputStream.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.compress.archivers.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; + + +/** + * {@link RandomAccessOutputStream} implementation for SeekableByteChannel. + */ +class SeekableChannelRandomAccessOutputStream extends RandomAccessOutputStream { + + private final SeekableByteChannel channel; + + private long position; + + SeekableChannelRandomAccessOutputStream(final SeekableByteChannel channel) { + this.channel = channel; + } + + @Override + public synchronized void write(final byte[] b, final int off, final int len) throws IOException { + ZipIoUtil.writeFully(this.channel, ByteBuffer.wrap(b, off, len)); + } + + @Override + public synchronized long position() throws IOException { + return channel.position(); + } + + @Override + public synchronized void writeFullyAt(final byte[] b, final int off, final int len, final long position) throws IOException { + long saved = channel.position(); + try { + channel.position(position); + ZipIoUtil.writeFully(channel, ByteBuffer.wrap(b, off, len)); + } finally { + channel.position(saved); + } + } + + @Override + public synchronized void close() throws IOException { + channel.close(); + } +} diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/ZipArchiveOutputStream.java b/src/main/java/org/apache/commons/compress/archivers/zip/ZipArchiveOutputStream.java index f9820535c..70aaa8979 100644 --- a/src/main/java/org/apache/commons/compress/archivers/zip/ZipArchiveOutputStream.java +++ b/src/main/java/org/apache/commons/compress/archivers/zip/ZipArchiveOutputStream.java @@ -17,19 +17,15 @@ package org.apache.commons.compress.archivers.zip; import java.io.ByteArrayOutputStream; -import java.io.Closeable; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.ByteBuffer; import java.nio.channels.SeekableByteChannel; -import java.nio.file.Files; import java.nio.file.LinkOption; import java.nio.file.OpenOption; import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.EnumSet; import java.util.HashMap; import java.util.LinkedList; import java.util.List; @@ -340,11 +336,6 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> */ protected final Deflater def; - /** - * Optional random access output. - */ - private final SeekableByteChannel channel; - private final OutputStream outputStream; /** @@ -424,7 +415,6 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> */ public ZipArchiveOutputStream(final OutputStream out) { this.outputStream = out; - this.channel = null; def = new Deflater(level, true); streamCompressor = StreamCompressor.create(out, def); isSplitZip = false; @@ -451,7 +441,6 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> def = new Deflater(level, true); this.outputStream = new ZipSplitOutputStream(path, zipSplitSize); streamCompressor = StreamCompressor.create(this.outputStream, def); - channel = null; isSplitZip = true; } @@ -465,24 +454,8 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> */ public ZipArchiveOutputStream(final Path file, final OpenOption... options) throws IOException { def = new Deflater(level, true); - OutputStream outputStream = null; - SeekableByteChannel channel = null; - StreamCompressor streamCompressor; - try { - channel = Files.newByteChannel(file, - EnumSet.of(StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.READ, StandardOpenOption.TRUNCATE_EXISTING)); - // will never get opened properly when an exception is thrown so doesn't need to get closed - streamCompressor = StreamCompressor.create(channel, def); // NOSONAR - } catch (final IOException e) { // NOSONAR - final Closeable c = channel; - org.apache.commons.io.IOUtils.closeQuietly(c); - channel = null; - outputStream = Files.newOutputStream(file, options); - streamCompressor = StreamCompressor.create(outputStream, def); - } - this.outputStream = outputStream; - this.channel = channel; - this.streamCompressor = streamCompressor; + this.outputStream = options.length == 0 ? new FileRandomAccessOutputStream(file) : new FileRandomAccessOutputStream(file, options); + this.streamCompressor = StreamCompressor.create(outputStream, def); this.isSplitZip = false; } @@ -497,10 +470,9 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> * @since 1.13 */ public ZipArchiveOutputStream(final SeekableByteChannel channel) { - this.channel = channel; + this.outputStream = new SeekableChannelRandomAccessOutputStream(channel); def = new Deflater(level, true); - streamCompressor = StreamCompressor.create(channel, def); - outputStream = null; + streamCompressor = StreamCompressor.create(outputStream, def); isSplitZip = false; } @@ -636,7 +608,7 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> } private void closeEntry(final boolean actuallyNeedsZip64, final boolean phased) throws IOException { - if (!phased && channel != null) { + if (!phased && outputStream instanceof RandomAccessOutputStream) { rewriteSizesAndCrc(actuallyNeedsZip64); } @@ -876,7 +848,7 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> ZipUtil.toDosTime(ze.getTime(), buf, LFH_TIME_OFFSET); // CRC - if (phased || !(zipMethod == DEFLATED || channel != null)) { + if (phased || !(zipMethod == DEFLATED || outputStream instanceof RandomAccessOutputStream)) { ZipLong.putLong(ze.getCrc(), buf, LFH_CRC_OFFSET); } else { System.arraycopy(LZERO, 0, buf, LFH_CRC_OFFSET, ZipConstants.WORD); @@ -893,7 +865,7 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> } else if (phased) { ZipLong.putLong(ze.getCompressedSize(), buf, LFH_COMPRESSED_SIZE_OFFSET); ZipLong.putLong(ze.getSize(), buf, LFH_ORIGINAL_SIZE_OFFSET); - } else if (zipMethod == DEFLATED || channel != null) { + } else if (zipMethod == DEFLATED || outputStream instanceof RandomAccessOutputStream) { System.arraycopy(LZERO, 0, buf, LFH_COMPRESSED_SIZE_OFFSET, ZipConstants.WORD); System.arraycopy(LZERO, 0, buf, LFH_ORIGINAL_SIZE_OFFSET, ZipConstants.WORD); } else { // Stored @@ -931,14 +903,8 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> * </p> */ void destroy() throws IOException { - try { - if (channel != null) { - channel.close(); - } - } finally { - if (outputStream != null) { - outputStream.close(); - } + if (outputStream != null) { + outputStream.close(); } } @@ -1034,7 +1000,8 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> * @since 1.3 */ private Zip64Mode getEffectiveZip64Mode(final ZipArchiveEntry ze) { - if (zip64Mode != Zip64Mode.AsNeeded || channel != null || ze.getMethod() != DEFLATED || ze.getSize() != ArchiveEntry.SIZE_UNKNOWN) { + if (zip64Mode != Zip64Mode.AsNeeded || outputStream instanceof RandomAccessOutputStream || + ze.getMethod() != DEFLATED || ze.getSize() != ArchiveEntry.SIZE_UNKNOWN) { return zip64Mode; } return Zip64Mode.Never; @@ -1106,7 +1073,7 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> entry.entry.setCompressedSize(bytesWritten); entry.entry.setCrc(crc); - } else if (channel == null) { + } else if (!(outputStream instanceof RandomAccessOutputStream)) { if (entry.entry.getCrc() != crc) { throw new ZipException("Bad CRC checksum for entry " + entry.entry.getName() + ": " + Long.toHexString(entry.entry.getCrc()) + " instead of " + Long.toHexString(crc)); @@ -1172,7 +1139,7 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> * @return true if seekable */ public boolean isSeekable() { - return channel != null; + return outputStream instanceof RandomAccessOutputStream; } private boolean isTooLargeForZip32(final ZipArchiveEntry zipArchiveEntry) { @@ -1271,33 +1238,38 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> * sizes. */ private void rewriteSizesAndCrc(final boolean actuallyNeedsZip64) throws IOException { - final long save = channel.position(); + RandomAccessOutputStream randomStream = (RandomAccessOutputStream) outputStream; + long dataStart = entry.localDataStart; + if (randomStream instanceof ZipSplitOutputStream) { + dataStart = ((ZipSplitOutputStream) randomStream).calculateDiskPosition(entry.entry.getDiskNumberStart(), dataStart); + } - channel.position(entry.localDataStart); - writeOut(ZipLong.getBytes(entry.entry.getCrc())); + long position = dataStart; + randomStream.writeFullyAt(ZipLong.getBytes(entry.entry.getCrc()), position); position += ZipConstants.WORD; if (!hasZip64Extra(entry.entry) || !actuallyNeedsZip64) { - writeOut(ZipLong.getBytes(entry.entry.getCompressedSize())); - writeOut(ZipLong.getBytes(entry.entry.getSize())); + randomStream.writeFullyAt(ZipLong.getBytes(entry.entry.getCompressedSize()), position); position += ZipConstants.WORD; + randomStream.writeFullyAt(ZipLong.getBytes(entry.entry.getSize()), position); position += ZipConstants.WORD; } else { - writeOut(ZipLong.ZIP64_MAGIC.getBytes()); - writeOut(ZipLong.ZIP64_MAGIC.getBytes()); + randomStream.writeFullyAt(ZipLong.ZIP64_MAGIC.getBytes(), position); position += ZipConstants.WORD; + randomStream.writeFullyAt(ZipLong.ZIP64_MAGIC.getBytes(), position); position += ZipConstants.WORD; } if (hasZip64Extra(entry.entry)) { final ByteBuffer name = getName(entry.entry); final int nameLen = name.limit() - name.position(); // seek to ZIP64 extra, skip header and size information - channel.position(entry.localDataStart + 3 * ZipConstants.WORD + 2 * ZipConstants.SHORT + nameLen + 2 * ZipConstants.SHORT); + position = dataStart + 3 * ZipConstants.WORD + 2 * ZipConstants.SHORT + nameLen + 2 * ZipConstants.SHORT; // inside the ZIP64 extra uncompressed size comes // first, unlike the LFH, CD or data descriptor - writeOut(ZipEightByteInteger.getBytes(entry.entry.getSize())); - writeOut(ZipEightByteInteger.getBytes(entry.entry.getCompressedSize())); + randomStream.writeFullyAt(ZipEightByteInteger.getBytes(entry.entry.getSize()), position); position += ZipConstants.DWORD; + randomStream.writeFullyAt(ZipEightByteInteger.getBytes(entry.entry.getCompressedSize()), position); position += ZipConstants.DWORD; if (!actuallyNeedsZip64) { // do some cleanup: // * rewrite version needed to extract - channel.position(entry.localDataStart - 5 * ZipConstants.SHORT); - writeOut(ZipShort.getBytes(versionNeededToExtract(entry.entry.getMethod(), false, false))); + position = dataStart - 5 * ZipConstants.SHORT; + randomStream.writeFullyAt(ZipShort.getBytes(versionNeededToExtract(entry.entry.getMethod(), false, false)), position); + position += ZipConstants.SHORT; // * remove ZIP64 extra, so it doesn't get written // to the central directory @@ -1311,7 +1283,6 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> } } } - channel.position(save); } /** @@ -1470,7 +1441,7 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> private boolean shouldAddZip64Extra(final ZipArchiveEntry entry, final Zip64Mode mode) { return mode == Zip64Mode.Always || mode == Zip64Mode.AlwaysWithCompatibility || entry.getSize() >= ZipConstants.ZIP64_MAGIC || entry.getCompressedSize() >= ZipConstants.ZIP64_MAGIC - || entry.getSize() == ArchiveEntry.SIZE_UNKNOWN && channel != null && mode != Zip64Mode.Never; + || entry.getSize() == ArchiveEntry.SIZE_UNKNOWN && outputStream instanceof RandomAccessOutputStream && mode != Zip64Mode.Never; } /** @@ -1496,7 +1467,7 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> } private boolean usesDataDescriptor(final int zipMethod, final boolean phased) { - return !phased && zipMethod == DEFLATED && channel == null; + return !phased && zipMethod == DEFLATED && !(outputStream instanceof RandomAccessOutputStream); } /** @@ -1547,7 +1518,7 @@ public class ZipArchiveOutputStream extends ArchiveOutputStream<ZipArchiveEntry> */ private void validateSizeInformation(final Zip64Mode effectiveMode) throws ZipException { // Size/CRC not required if SeekableByteChannel is used - if (entry.entry.getMethod() == STORED && channel == null) { + if (entry.entry.getMethod() == STORED && !(outputStream instanceof RandomAccessOutputStream)) { if (entry.entry.getSize() == ArchiveEntry.SIZE_UNKNOWN) { throw new ZipException("Uncompressed size is required for" + " STORED method when not writing to a" + " file"); } diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/ZipFile.java b/src/main/java/org/apache/commons/compress/archivers/zip/ZipFile.java index ded114350..3337308fd 100644 --- a/src/main/java/org/apache/commons/compress/archivers/zip/ZipFile.java +++ b/src/main/java/org/apache/commons/compress/archivers/zip/ZipFile.java @@ -25,6 +25,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.SequenceInputStream; import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.nio.channels.FileChannel; import java.nio.channels.SeekableByteChannel; import java.nio.charset.Charset; @@ -33,6 +34,7 @@ import java.nio.file.Files; import java.nio.file.OpenOption; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; @@ -42,6 +44,8 @@ import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import java.util.zip.Inflater; import java.util.zip.ZipException; @@ -55,6 +59,7 @@ import org.apache.commons.compress.utils.IOUtils; import org.apache.commons.compress.utils.InputStreamStatistics; import org.apache.commons.compress.utils.SeekableInMemoryByteChannel; import org.apache.commons.io.Charsets; +import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.build.AbstractOrigin.ByteArrayOrigin; import org.apache.commons.io.build.AbstractStreamBuilder; import org.apache.commons.io.input.CountingInputStream; @@ -128,6 +133,7 @@ public class ZipFile implements Closeable { private SeekableByteChannel seekableByteChannel; private boolean useUnicodeExtraFields = true; private boolean ignoreLocalFileHeader; + private long maxNumberOfDisks = 1; public Builder() { setCharset(DEFAULT_CHARSET); @@ -151,7 +157,7 @@ public class ZipFile implements Closeable { openOptions = new OpenOption[] { StandardOpenOption.READ }; } final Path path = getPath(); - actualChannel = Files.newByteChannel(path, openOptions); + actualChannel = openZipChannel(path, maxNumberOfDisks, openOptions); actualDescription = path.toString(); } final boolean closeOnError = seekableByteChannel != null; @@ -191,6 +197,19 @@ public class ZipFile implements Closeable { return this; } + /** + * Sets max number of multi archive disks, default is 1 (no multi archive). + * + * @param maxNumberOfDisks + * max number of multi archive disks + * + * @return this instance + */ + public Builder setMaxNumberOfDisks(final long maxNumberOfDisks) { + this.maxNumberOfDisks = maxNumberOfDisks; + return this; + } + } /** @@ -545,6 +564,7 @@ public class ZipFile implements Closeable { private long firstLocalFileHeaderOffset; + /** * Opens the given file for reading, assuming "UTF8" for file names. * @@ -812,6 +832,66 @@ public class ZipFile implements Closeable { this(new File(name).toPath(), encoding, true); } + private static SeekableByteChannel openZipChannel(Path path, long maxNumberOfDisks, OpenOption[] openOptions) throws IOException { + FileChannel channel = FileChannel.open(path, StandardOpenOption.READ); + List<FileChannel> channels = new ArrayList<>(); + try { + boolean is64 = positionAtEndOfCentralDirectoryRecord(channel); + long numberOfDisks; + if (is64) { + channel.position(channel.position() + ZipConstants.WORD + ZipConstants.WORD + ZipConstants.DWORD); + ByteBuffer buf = ByteBuffer.allocate(ZipConstants.WORD); + buf.order(ByteOrder.LITTLE_ENDIAN); + IOUtils.readFully(channel, buf); + buf.flip(); + numberOfDisks = buf.getInt() & 0xffffffffL; + } else { + channel.position(channel.position() + ZipConstants.WORD); + ByteBuffer buf = ByteBuffer.allocate(ZipConstants.SHORT); + buf.order(ByteOrder.LITTLE_ENDIAN); + IOUtils.readFully(channel, buf); + buf.flip(); + numberOfDisks = (buf.getShort() & 0xffff) + 1; + } + if (numberOfDisks > Math.min(maxNumberOfDisks, Integer.MAX_VALUE)) { + throw new IOException("Too many disks for zip archive, max=" + + Math.min(maxNumberOfDisks, Integer.MAX_VALUE) + " actual=" + numberOfDisks); + } + + if (numberOfDisks <= 1) { + return channel; + } + channel.close(); + + Path parent = path.getParent(); + String basename = FilenameUtils.removeExtension(path.getFileName().toString()); + + return ZipSplitReadOnlySeekableByteChannel.forPaths( + IntStream.range(0, (int) numberOfDisks) + .mapToObj(i -> { + if (i == numberOfDisks - 1) { + return path; + } + Path lowercase = parent.resolve(String.format("%s.z%02d", basename, i + 1)); + if (Files.exists(lowercase)) { + return lowercase; + } + Path uppercase = parent.resolve(String.format("%s.Z%02d", basename, i + 1)); + if (Files.exists(uppercase)) { + return uppercase; + } + return lowercase; + }) + .collect(Collectors.toList()), + openOptions + ); + } catch (Throwable ex) { + IOUtils.closeQuietly(channel); + channels.forEach(IOUtils::closeQuietly); + throw ex; + } + } + /** * Whether this class is able to read the given entry. * <p> @@ -1152,20 +1232,8 @@ public class ZipFile implements Closeable { * stream at the first central directory record. */ private void positionAtCentralDirectory() throws IOException { - positionAtEndOfCentralDirectoryRecord(); - boolean found = false; - final boolean searchedForZip64EOCD = archive.position() > ZIP64_EOCDL_LENGTH; - if (searchedForZip64EOCD) { - archive.position(archive.position() - ZIP64_EOCDL_LENGTH); - wordBbuf.rewind(); - IOUtils.readFully(archive, wordBbuf); - found = Arrays.equals(ZipArchiveOutputStream.ZIP64_EOCD_LOC_SIG, wordBuf); - } - if (!found) { - // not a ZIP64 archive - if (searchedForZip64EOCD) { - skipBytes(ZIP64_EOCDL_LENGTH - ZipConstants.WORD); - } + boolean is64 = positionAtEndOfCentralDirectoryRecord(archive); + if (!is64) { positionAtCentralDirectory32(); } else { positionAtCentralDirectory64(); @@ -1214,6 +1282,7 @@ public class ZipFile implements Closeable { * Expects stream to be positioned right behind the "Zip64 end of central directory locator"'s signature. */ private void positionAtCentralDirectory64() throws IOException { + skipBytes(ZipConstants.WORD); if (isSplitZipArchive) { wordBbuf.rewind(); IOUtils.readFully(archive, wordBbuf); @@ -1260,12 +1329,32 @@ public class ZipFile implements Closeable { /** * Searches for the and positions the stream at the start of the "End of central dir record". + * + * @return + * true if it's Zip64 end of central directory or false if it's Zip32 */ - private void positionAtEndOfCentralDirectoryRecord() throws IOException { - final boolean found = tryToLocateSignature(MIN_EOCD_SIZE, MAX_EOCD_SIZE, ZipArchiveOutputStream.EOCD_SIG); + private static boolean positionAtEndOfCentralDirectoryRecord(SeekableByteChannel channel) throws IOException { + final boolean found = tryToLocateSignature(channel, MIN_EOCD_SIZE, MAX_EOCD_SIZE, ZipArchiveOutputStream.EOCD_SIG); if (!found) { throw new ZipException("Archive is not a ZIP archive"); } + boolean found64 = false; + long position = channel.position(); + if (position > ZIP64_EOCDL_LENGTH) { + ByteBuffer wordBuf = ByteBuffer.allocate(4); + channel.position(channel.position() - ZIP64_EOCDL_LENGTH); + wordBuf.rewind(); + IOUtils.readFully(channel, wordBuf); + wordBuf.flip(); + found64 = wordBuf.equals(ByteBuffer.wrap(ZipArchiveOutputStream.ZIP64_EOCD_LOC_SIG)); + if (!found64) { + channel.position(position); + } else { + channel.position(channel.position() - ZipConstants.WORD); + } + } + + return found64; } /** @@ -1553,27 +1642,33 @@ public class ZipFile implements Closeable { * Searches the archive backwards from minDistance to maxDistance for the given signature, positions the RandomaccessFile right at the signature if it has * been found. */ - private boolean tryToLocateSignature(final long minDistanceFromEnd, final long maxDistanceFromEnd, final byte[] sig) throws IOException { + private static boolean tryToLocateSignature( + final SeekableByteChannel channel, + final long minDistanceFromEnd, + final long maxDistanceFromEnd, + final byte[] sig + ) throws IOException { + ByteBuffer wordBuf = ByteBuffer.allocate(ZipConstants.WORD); boolean found = false; - long off = archive.size() - minDistanceFromEnd; - final long stopSearching = Math.max(0L, archive.size() - maxDistanceFromEnd); + long off = channel.size() - minDistanceFromEnd; + final long stopSearching = Math.max(0L, channel.size() - maxDistanceFromEnd); if (off >= 0) { for (; off >= stopSearching; off--) { - archive.position(off); + channel.position(off); try { - wordBbuf.rewind(); - IOUtils.readFully(archive, wordBbuf); - wordBbuf.flip(); + wordBuf.rewind(); + IOUtils.readFully(channel, wordBuf); + wordBuf.flip(); } catch (final EOFException ex) { // NOSONAR break; } - int curr = wordBbuf.get(); + int curr = wordBuf.get(); if (curr == sig[POS_0]) { - curr = wordBbuf.get(); + curr = wordBuf.get(); if (curr == sig[POS_1]) { - curr = wordBbuf.get(); + curr = wordBuf.get(); if (curr == sig[POS_2]) { - curr = wordBbuf.get(); + curr = wordBuf.get(); if (curr == sig[POS_3]) { found = true; break; @@ -1584,7 +1679,7 @@ public class ZipFile implements Closeable { } } if (found) { - archive.position(off); + channel.position(off); } return found; } diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/ZipIoUtil.java b/src/main/java/org/apache/commons/compress/archivers/zip/ZipIoUtil.java new file mode 100644 index 000000000..bc6840bd0 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/zip/ZipIoUtil.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.commons.compress.archivers.zip; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.SeekableByteChannel; + + +/** + * IO utilities for Zip operations. + * + * Package private to potentially move to something reusable. + */ +class ZipIoUtil { + /** + * Writes full buffer to channel. + * + * @param channel + * channel to write to + * @param buf + * buffer to write + * @throws IOException + * when writing fails or fails to write fully + */ + static void writeFully(SeekableByteChannel channel, ByteBuffer buf) throws IOException { + while (buf.hasRemaining()) { + int remaining = buf.remaining(); + int written = channel.write(buf); + if (written <= 0) { + throw new IOException("Failed to fully write: channel=" + channel + " length=" + remaining + " written=" + written); + } + } + } + + /** + * Writes full buffer to channel at specified position. + * + * @param channel + * channel to write to + * @param buf + * buffer to write + * @param position + * position to write at + * @throws IOException + * when writing fails or fails to write fully + */ + static void writeFullyAt(FileChannel channel, ByteBuffer buf, long position) throws IOException { + for (long currentPosition = position; buf.hasRemaining(); ) { + int remaining = buf.remaining(); + int written = channel.write(buf, currentPosition); + if (written <= 0) { + throw new IOException("Failed to fully write: channel=" + channel + " length=" + remaining + " written=" + written); + } + currentPosition += written; + } + } + + private ZipIoUtil() { + } +} diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/ZipSplitOutputStream.java b/src/main/java/org/apache/commons/compress/archivers/zip/ZipSplitOutputStream.java index c8aae07ca..8d05ecfe5 100644 --- a/src/main/java/org/apache/commons/compress/archivers/zip/ZipSplitOutputStream.java +++ b/src/main/java/org/apache/commons/compress/archivers/zip/ZipSplitOutputStream.java @@ -18,11 +18,17 @@ package org.apache.commons.compress.archivers.zip; import java.io.File; import java.io.IOException; -import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.TreeMap; import org.apache.commons.compress.utils.FileNameUtils; @@ -31,7 +37,7 @@ import org.apache.commons.compress.utils.FileNameUtils; * * @since 1.20 */ -final class ZipSplitOutputStream extends OutputStream { +final class ZipSplitOutputStream extends RandomAccessOutputStream { /** * 8.5.1 Capacities for split archives are as follows: @@ -42,14 +48,20 @@ final class ZipSplitOutputStream extends OutputStream { private static final long ZIP_SEGMENT_MIN_SIZE = 64 * 1024L; private static final long ZIP_SEGMENT_MAX_SIZE = 4294967295L; - private OutputStream outputStream; + private FileChannel currentChannel; + private FileRandomAccessOutputStream outputStream; private Path zipFile; private final long splitSize; + private long totalPosition; private int currentSplitSegmentIndex; private long currentSplitSegmentBytesWritten; private boolean finished; private final byte[] singleByte = new byte[1]; + private List<Long> diskToPosition = new ArrayList<>(); + + private TreeMap<Long, Path> positionToFiles = new TreeMap<>(); + /** * Creates a split ZIP. If the ZIP file is smaller than the split size, then there will only be one split ZIP, and its suffix is .zip, otherwise the split * segments should be like .z01, .z02, ... .z(N-1), .zip @@ -79,7 +91,10 @@ final class ZipSplitOutputStream extends OutputStream { } this.zipFile = zipFile; this.splitSize = splitSize; - this.outputStream = Files.newOutputStream(zipFile); + this.outputStream = new FileRandomAccessOutputStream(zipFile); + this.currentChannel = this.outputStream.channel(); + this.positionToFiles.put(0L, this.zipFile); + this.diskToPosition.add(0L); // write the ZIP split signature 0x08074B50 to the ZIP file writeZipSplitSignature(); } @@ -107,6 +122,15 @@ final class ZipSplitOutputStream extends OutputStream { * @throws IOException */ private Path createNewSplitSegmentFile(final Integer zipSplitSegmentSuffixIndex) throws IOException { + Path newFile = getSplitSegmentFilename(zipSplitSegmentSuffixIndex); + + if (Files.exists(newFile)) { + throw new IOException("split ZIP segment " + newFile + " already exists"); + } + return newFile; + } + + private Path getSplitSegmentFilename(final Integer zipSplitSegmentSuffixIndex) throws IOException { final int newZipSplitSegmentSuffixIndex = zipSplitSegmentSuffixIndex == null ? currentSplitSegmentIndex + 2 : zipSplitSegmentSuffixIndex; final String baseName = FileNameUtils.getBaseName(zipFile); String extension = ".z"; @@ -120,12 +144,10 @@ final class ZipSplitOutputStream extends OutputStream { final String dir = Objects.nonNull(parent) ? parent.toAbsolutePath().toString() : "."; final Path newFile = zipFile.getFileSystem().getPath(dir, baseName + extension); - if (Files.exists(newFile)) { - throw new IOException("split ZIP segment " + baseName + extension + " already exists"); - } return newFile; } + /** * The last ZIP split segment's suffix should be .zip * @@ -161,16 +183,26 @@ final class ZipSplitOutputStream extends OutputStream { outputStream.close(); newFile = createNewSplitSegmentFile(1); Files.move(zipFile, newFile, StandardCopyOption.ATOMIC_MOVE); + this.positionToFiles.put(0L, newFile); } newFile = createNewSplitSegmentFile(null); outputStream.close(); - outputStream = Files.newOutputStream(newFile); + outputStream = new FileRandomAccessOutputStream(newFile); + currentChannel = outputStream.channel(); currentSplitSegmentBytesWritten = 0; zipFile = newFile; currentSplitSegmentIndex++; + this.diskToPosition.add(this.totalPosition); + this.positionToFiles.put(this.totalPosition, newFile); + } + public long calculateDiskPosition(long disk, long localOffset) throws IOException { + if (disk >= Integer.MAX_VALUE) { + throw new IOException("Disk number exceeded internal limits: limit=" + Integer.MAX_VALUE + " requested=" + disk); + } + return diskToPosition.get((int) disk) + localOffset; } /** @@ -224,6 +256,7 @@ final class ZipSplitOutputStream extends OutputStream { } else { outputStream.write(b, off, len); currentSplitSegmentBytesWritten += len; + totalPosition += len; } } @@ -233,6 +266,49 @@ final class ZipSplitOutputStream extends OutputStream { write(singleByte); } + @Override + public long position() { + return totalPosition; + } + + @Override + public void writeFullyAt(final byte[] b, final int off, final int len, final long atPosition) throws IOException { + long remainingPosition = atPosition; + for (int remainingOff = off, remainingLen = len; remainingLen > 0; ) { + Map.Entry<Long, Path> segment = positionToFiles.floorEntry(remainingPosition); + Long segmentEnd = positionToFiles.higherKey(remainingPosition); + if (segmentEnd == null) { + ZipIoUtil.writeFullyAt(this.currentChannel, ByteBuffer.wrap(b, remainingOff, remainingLen), remainingPosition - segment.getKey()); + remainingPosition += remainingLen; + remainingOff += remainingLen; + remainingLen = 0; + } else if (remainingPosition + remainingLen <= segmentEnd) { + writeToSegment(segment.getValue(), remainingPosition - segment.getKey(), b, remainingOff, remainingLen); + remainingPosition += remainingLen; + remainingOff += remainingLen; + remainingLen = 0; + } else { + int toWrite = Math.toIntExact(segmentEnd - remainingPosition); + writeToSegment(segment.getValue(), remainingPosition - segment.getKey(), b, remainingOff, toWrite); + remainingPosition += toWrite; + remainingOff += toWrite; + remainingLen -= toWrite; + } + } + } + + private void writeToSegment( + final Path segment, + final long position, + final byte[] b, + final int off, + final int len + ) throws IOException { + try (FileChannel channel = FileChannel.open(segment, StandardOpenOption.WRITE)) { + ZipIoUtil.writeFullyAt(channel, ByteBuffer.wrap(b, off, len), position); + } + } + /** * Write the ZIP split signature (0x08074B50) to the head of the first ZIP split segment * @@ -241,5 +317,6 @@ final class ZipSplitOutputStream extends OutputStream { private void writeZipSplitSignature() throws IOException { outputStream.write(ZipArchiveOutputStream.DD_SIG); currentSplitSegmentBytesWritten += ZipArchiveOutputStream.DD_SIG.length; + totalPosition += ZipArchiveOutputStream.DD_SIG.length; } } diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/ZipSplitReadOnlySeekableByteChannel.java b/src/main/java/org/apache/commons/compress/archivers/zip/ZipSplitReadOnlySeekableByteChannel.java index a79b4c43b..20d7b8bed 100644 --- a/src/main/java/org/apache/commons/compress/archivers/zip/ZipSplitReadOnlySeekableByteChannel.java +++ b/src/main/java/org/apache/commons/compress/archivers/zip/ZipSplitReadOnlySeekableByteChannel.java @@ -23,6 +23,7 @@ import java.io.Serializable; import java.nio.ByteBuffer; import java.nio.channels.SeekableByteChannel; import java.nio.file.Files; +import java.nio.file.OpenOption; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.ArrayList; @@ -203,9 +204,24 @@ public class ZipSplitReadOnlySeekableByteChannel extends MultiReadOnlySeekableBy * @since 1.22 */ public static SeekableByteChannel forPaths(final Path... paths) throws IOException { + return forPaths(Arrays.asList(paths), new OpenOption[]{ StandardOpenOption.READ }); + } + + /** + * Concatenates the given file paths. + * + * @param paths the file paths to concatenate, note that the LAST FILE of files should be the LAST SEGMENT(.zip) and these files should be added in correct + * order (e.g.: .z01, .z02... .z99, .zip) + * @return SeekableByteChannel that concatenates all provided files + * @throws NullPointerException if files is null + * @throws IOException if opening a channel for one of the files fails + * @throws IOException if the first channel doesn't seem to hold the beginning of a split archive + * @since 1.22 + */ + public static SeekableByteChannel forPaths(final List<Path> paths, OpenOption[] openOptions) throws IOException { final List<SeekableByteChannel> channels = new ArrayList<>(); for (final Path path : Objects.requireNonNull(paths, "paths must not be null")) { - channels.add(Files.newByteChannel(path, StandardOpenOption.READ)); + channels.add(Files.newByteChannel(path, openOptions)); } if (channels.size() == 1) { return channels.get(0); diff --git a/src/test/java/org/apache/commons/compress/archivers/ZipTest.java b/src/test/java/org/apache/commons/compress/archivers/ZipTest.java index fb2b6493b..c48e7dcd5 100644 --- a/src/test/java/org/apache/commons/compress/archivers/ZipTest.java +++ b/src/test/java/org/apache/commons/compress/archivers/ZipTest.java @@ -29,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -135,6 +136,14 @@ public final class ZipTest extends AbstractTest { return result; } + private byte[] createArtificialData(int size) { + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + for (int i = 0; i < size; i += 1) { + output.write((byte) ((i & 1) == 0 ? (i / 2 % 256) : (i / 2 / 256))); + } + return output.toByteArray(); + } + private void createArchiveEntry(final String payload, final ZipArchiveOutputStream zos, final String name) throws IOException { final ZipArchiveEntry in = new ZipArchiveEntry(name); zos.putArchiveEntry(in); @@ -250,6 +259,155 @@ public final class ZipTest extends AbstractTest { } } + /** + * Tests split archive with 32-bit limit, both STORED and DEFLATED. + */ + @Test + public void testBuildArtificialSplitZip32Test() throws IOException { + final File outputZipFile = newTempFile("artificialSplitZip.zip"); + final long splitSize = 64 * 1024L; /* 64 KB */ + try (ZipArchiveOutputStream zipArchiveOutputStream = new ZipArchiveOutputStream(outputZipFile, splitSize)) { + zipArchiveOutputStream.setUseZip64(Zip64Mode.Never); + ZipArchiveEntry ze1 = new ZipArchiveEntry("file01"); + ze1.setMethod(ZipEntry.STORED); + zipArchiveOutputStream.putArchiveEntry(ze1); + zipArchiveOutputStream.write(createArtificialData(65536)); + zipArchiveOutputStream.closeArchiveEntry(); + ZipArchiveEntry ze2 = new ZipArchiveEntry("file02"); + ze2.setMethod(ZipEntry.DEFLATED); + zipArchiveOutputStream.putArchiveEntry(ze2); + zipArchiveOutputStream.write(createArtificialData(65536)); + zipArchiveOutputStream.closeArchiveEntry(); + } + + try (ZipFile zipFile = ZipFile.builder() + .setPath(outputZipFile.toPath()) + .setMaxNumberOfDisks(Integer.MAX_VALUE) + .get() + ) { + assertArrayEquals(createArtificialData(65536), IOUtils.toByteArray(zipFile.getInputStream(zipFile.getEntry("file01")))); + assertArrayEquals(createArtificialData(65536), IOUtils.toByteArray(zipFile.getInputStream(zipFile.getEntry("file02")))); + } + } + + /** + * Tests split archive with 64-bit limit, both STORED and DEFLATED. + */ + @Test + public void testBuildArtificialSplitZip64Test() throws IOException { + final File outputZipFile = newTempFile("artificialSplitZip.zip"); + final long splitSize = 64 * 1024L; /* 64 KB */ + byte[] data = createArtificialData(128 * 1024); + try (ZipArchiveOutputStream zipArchiveOutputStream = new ZipArchiveOutputStream(outputZipFile, splitSize)) { + zipArchiveOutputStream.setUseZip64(Zip64Mode.Always); + ZipArchiveEntry ze1 = new ZipArchiveEntry("file01"); + ze1.setMethod(ZipEntry.STORED); + zipArchiveOutputStream.putArchiveEntry(ze1); + zipArchiveOutputStream.write(data); + zipArchiveOutputStream.closeArchiveEntry(); + ZipArchiveEntry ze2 = new ZipArchiveEntry("file02"); + ze2.setMethod(ZipEntry.DEFLATED); + zipArchiveOutputStream.putArchiveEntry(ze2); + zipArchiveOutputStream.write(data); + zipArchiveOutputStream.closeArchiveEntry(); + } + + try (ZipFile zipFile = ZipFile.builder() + .setPath(outputZipFile.toPath()) + .setMaxNumberOfDisks(Integer.MAX_VALUE) + .get() + ) { + assertArrayEquals(data, IOUtils.toByteArray(zipFile.getInputStream(zipFile.getEntry("file01")))); + assertArrayEquals(data, IOUtils.toByteArray(zipFile.getInputStream(zipFile.getEntry("file02")))); + } + } + + /** + * Tests split archive with 32-bit limit, with file local headers crossing segment boundaries. + */ + @Test + public void testBuildSplitZip32_metaCrossBoundary() throws IOException { + final File outputZipFile = newTempFile("artificialSplitZip.zip"); + final long splitSize = 64 * 1024L; /* 64 KB */ + // 4 is PK signature, 36 is size of header + local file header, + // 15 is next local file header up to second byte of CRC + byte[] data1 = createArtificialData(64 * 1024 - 4 - 36 - 15); + // 21 is remaining size of second local file header + // 19 is next local file header up to second byte of compressed size + byte[] data2 = createArtificialData(64 * 1024 - 21 - 19); + // 17 is remaining size of third local file header + // 23 is next local file header up to second byte of uncompressed size + byte[] data3 = createArtificialData(64 * 1024 - 17 - 23); + // 13 is remaining size of third local file header + // 1 is to wrap to next part + byte[] data4 = createArtificialData(64 * 1024 - 13 + 1); + try (ZipArchiveOutputStream zipArchiveOutputStream = new ZipArchiveOutputStream(outputZipFile, splitSize)) { + zipArchiveOutputStream.setUseZip64(Zip64Mode.Never); + ZipArchiveEntry ze1 = new ZipArchiveEntry("file01"); + ze1.setMethod(ZipEntry.STORED); + zipArchiveOutputStream.putArchiveEntry(ze1); + zipArchiveOutputStream.write(data1); + zipArchiveOutputStream.closeArchiveEntry(); + ZipArchiveEntry ze2 = new ZipArchiveEntry("file02"); + ze2.setMethod(ZipEntry.STORED); + zipArchiveOutputStream.putArchiveEntry(ze2); + zipArchiveOutputStream.write(data2); + zipArchiveOutputStream.closeArchiveEntry(); + ZipArchiveEntry ze3 = new ZipArchiveEntry("file03"); + ze3.setMethod(ZipEntry.STORED); + zipArchiveOutputStream.putArchiveEntry(ze3); + zipArchiveOutputStream.write(data3); + zipArchiveOutputStream.closeArchiveEntry(); + ZipArchiveEntry ze4 = new ZipArchiveEntry("file04"); + ze4.setMethod(ZipEntry.STORED); + zipArchiveOutputStream.putArchiveEntry(ze4); + zipArchiveOutputStream.write(data4); + zipArchiveOutputStream.closeArchiveEntry(); + } + + try (ZipFile zipFile = ZipFile.builder() + .setPath(outputZipFile.toPath()) + .setMaxNumberOfDisks(Integer.MAX_VALUE) + .get() + ) { + assertArrayEquals(data1, IOUtils.toByteArray(zipFile.getInputStream(zipFile.getEntry("file01")))); + assertArrayEquals(data2, IOUtils.toByteArray(zipFile.getInputStream(zipFile.getEntry("file02")))); + assertArrayEquals(data3, IOUtils.toByteArray(zipFile.getInputStream(zipFile.getEntry("file03")))); + assertArrayEquals(data4, IOUtils.toByteArray(zipFile.getInputStream(zipFile.getEntry("file04")))); + } + } + + /** + * Tests split archive with 32-bit limit, with end of central directory skipping lack of space in segment. + */ + @Test + public void testBuildSplitZip32_endOfCentralDirectorySkipBoundary() throws IOException { + final File outputZipFile = newTempFile("artificialSplitZip.zip"); + final long splitSize = 64 * 1024L; /* 64 KB */ + // 4 is PK signature, 36 is size of header + local file header, + // 36 is length of central directory entry + // 1 is remaining byte in first archive, this should be skipped + byte[] data1 = createArtificialData(64 * 1024 - 4 - 36 - 52 - 1); + try (ZipArchiveOutputStream zipArchiveOutputStream = new ZipArchiveOutputStream(outputZipFile, splitSize)) { + zipArchiveOutputStream.setUseZip64(Zip64Mode.Never); + ZipArchiveEntry ze1 = new ZipArchiveEntry("file01"); + ze1.setMethod(ZipEntry.STORED); + zipArchiveOutputStream.putArchiveEntry(ze1); + zipArchiveOutputStream.write(data1); + zipArchiveOutputStream.closeArchiveEntry(); + } + + assertEquals(64 * 1024L - 1, Files.size(outputZipFile.toPath().getParent().resolve("artificialSplitZip.z01"))); + + try (ZipFile zipFile = ZipFile.builder() + .setPath(outputZipFile.toPath()) + .setMaxNumberOfDisks(Integer.MAX_VALUE) + .get() + ) { + assertArrayEquals(data1, IOUtils.toByteArray(zipFile.getInputStream(zipFile.getEntry("file01")))); + } + } + @Test public void testBuildSplitZipWithSegmentAlreadyExistThrowsException() throws IOException { final File directoryToZip = getFilesToZip(); diff --git a/src/test/java/org/apache/commons/compress/archivers/zip/FileRandomAccessOutputStreamTest.java b/src/test/java/org/apache/commons/compress/archivers/zip/FileRandomAccessOutputStreamTest.java new file mode 100644 index 000000000..e23dbf05b --- /dev/null +++ b/src/test/java/org/apache/commons/compress/archivers/zip/FileRandomAccessOutputStreamTest.java @@ -0,0 +1,150 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.compress.archivers.zip; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import org.apache.commons.compress.AbstractTempDirTest; +import org.junit.jupiter.api.Test; + + +public class FileRandomAccessOutputStreamTest extends AbstractTempDirTest { + @Test + public void testChannelReturn() throws IOException { + Path file = newTempPath("testChannel"); + try (FileRandomAccessOutputStream stream = new FileRandomAccessOutputStream(file)) { + assertNotNull(stream.channel()); + } + } + + @Test + public void testWrite() throws IOException { + FileChannel channel = mock(FileChannel.class); + FileRandomAccessOutputStream stream = new FileRandomAccessOutputStream(channel); + when(channel.write((ByteBuffer) any())) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(5); + return 5; + }) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(6); + return 6; + }); + stream.write("hello".getBytes(StandardCharsets.UTF_8)); + stream.write("world\n".getBytes(StandardCharsets.UTF_8)); + verify(channel, times(2)) + .write((ByteBuffer) any()); + + assertEquals(11, stream.position()); + } + + @Test + public void testWriteFullyAt_whenFullAtOnce_thenSucceed() throws IOException { + FileChannel channel = mock(FileChannel.class); + FileRandomAccessOutputStream stream = new FileRandomAccessOutputStream(channel); + when(channel.write((ByteBuffer) any(), eq(20L))) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(5); + return 5; + }); + when(channel.write((ByteBuffer) any(), eq(30L))) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(6); + return 6; + }); + stream.writeFullyAt("hello".getBytes(StandardCharsets.UTF_8), 20); + stream.writeFullyAt("world\n".getBytes(StandardCharsets.UTF_8), 30); + + verify(channel, times(1)) + .write((ByteBuffer) any(), eq(20L)); + verify(channel, times(1)) + .write((ByteBuffer) any(), eq(30L)); + + assertEquals(0, stream.position()); + } + + @Test + public void testWriteFullyAt_whenFullButPartial_thenSucceed() throws IOException { + FileChannel channel = mock(FileChannel.class); + FileRandomAccessOutputStream stream = new FileRandomAccessOutputStream(channel); + when(channel.write((ByteBuffer) any(), eq(20L))) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(3); + return 3; + }); + when(channel.write((ByteBuffer) any(), eq(23L))) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(5); + return 2; + }); + when(channel.write((ByteBuffer) any(), eq(30L))) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(6); + return 6; + }); + stream.writeFullyAt("hello".getBytes(StandardCharsets.UTF_8), 20); + stream.writeFullyAt("world\n".getBytes(StandardCharsets.UTF_8), 30); + + verify(channel, times(1)) + .write((ByteBuffer) any(), eq(20L)); + verify(channel, times(1)) + .write((ByteBuffer) any(), eq(23L)); + verify(channel, times(1)) + .write((ByteBuffer) any(), eq(30L)); + + assertEquals(0, stream.position()); + } + + @Test + public void testWriteFullyAt_whenPartial_thenFail() throws IOException { + FileChannel channel = mock(FileChannel.class); + FileRandomAccessOutputStream stream = new FileRandomAccessOutputStream(channel); + when(channel.write((ByteBuffer) any(), eq(20L))) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(3); + return 3; + }); + when(channel.write((ByteBuffer) any(), eq(23L))) + .thenAnswer(answer -> { + return 0; + }); + assertThrows(IOException.class, () -> stream.writeFullyAt("hello".getBytes(StandardCharsets.UTF_8), 20)); + + verify(channel, times(1)) + .write((ByteBuffer) any(), eq(20L)); + verify(channel, times(1)) + .write((ByteBuffer) any(), eq(23L)); + verify(channel, times(0)) + .write((ByteBuffer) any(), eq(25L)); + + assertEquals(0, stream.position()); + } +} diff --git a/src/test/java/org/apache/commons/compress/archivers/zip/RandomAccessOutputStreamTest.java b/src/test/java/org/apache/commons/compress/archivers/zip/RandomAccessOutputStreamTest.java new file mode 100644 index 000000000..d6ccc62ab --- /dev/null +++ b/src/test/java/org/apache/commons/compress/archivers/zip/RandomAccessOutputStreamTest.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.compress.archivers.zip; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.io.IOException; + +import org.apache.commons.compress.AbstractTempDirTest; +import org.junit.jupiter.api.Test; + + +public class RandomAccessOutputStreamTest extends AbstractTempDirTest { + + @Test + public void testWrite() throws IOException { + RandomAccessOutputStream delegate = mock(RandomAccessOutputStream.class); + + RandomAccessOutputStream stream = new RandomAccessOutputStream() { + @Override + public long position() throws IOException { + return delegate.position(); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + delegate.write(b, off, len); + } + + @Override + void writeFullyAt(byte[] b, int off, int len, long position) throws IOException { + delegate.writeFullyAt(b, off, len, position); + } + }; + + stream.write('\n'); + + verify(delegate, times(1)) + .write(any(), eq(0), eq(1)); + } +} diff --git a/src/test/java/org/apache/commons/compress/archivers/zip/SeekableChannelRandomAccessOutputStreamTest.java b/src/test/java/org/apache/commons/compress/archivers/zip/SeekableChannelRandomAccessOutputStreamTest.java new file mode 100644 index 000000000..2ea7ee6c4 --- /dev/null +++ b/src/test/java/org/apache/commons/compress/archivers/zip/SeekableChannelRandomAccessOutputStreamTest.java @@ -0,0 +1,168 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.compress.archivers.zip; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +import org.apache.commons.compress.AbstractTempDirTest; +import org.junit.jupiter.api.Test; + + +public class SeekableChannelRandomAccessOutputStreamTest extends AbstractTempDirTest { + + @Test + public void testInitialization() throws IOException { + Path file = newTempPath("testChannel"); + try (SeekableChannelRandomAccessOutputStream stream = new SeekableChannelRandomAccessOutputStream( + Files.newByteChannel(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE) + )) { + assertEquals(0, stream.position()); + } + } + + @Test + public void testWrite() throws IOException { + FileChannel channel = mock(FileChannel.class); + SeekableChannelRandomAccessOutputStream stream = new SeekableChannelRandomAccessOutputStream(channel); + + when(channel.position()) + .thenReturn(11L); + when(channel.write((ByteBuffer) any())) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(5); + return 5; + }) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(6); + return 6; + }); + + stream.write("hello".getBytes(StandardCharsets.UTF_8)); + stream.write("world\n".getBytes(StandardCharsets.UTF_8)); + + verify(channel, times(2)) + .write((ByteBuffer) any()); + + assertEquals(11, stream.position()); + } + + @Test + public void testWriteFullyAt_whenFullAtOnce_thenSucceed() throws IOException { + SeekableByteChannel channel = mock(SeekableByteChannel.class); + SeekableChannelRandomAccessOutputStream stream = new SeekableChannelRandomAccessOutputStream(channel); + + when(channel.position()) + .thenReturn(50L) + .thenReturn(60L); + when(channel.write((ByteBuffer) any())) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(5); + return 5; + }) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(6); + return 6; + }); + + stream.writeFullyAt("hello".getBytes(StandardCharsets.UTF_8), 20); + stream.writeFullyAt("world\n".getBytes(StandardCharsets.UTF_8), 30); + + verify(channel, times(2)) + .write((ByteBuffer) any()); + verify(channel, times(1)) + .position(eq(50L)); + verify(channel, times(1)) + .position(eq(60L)); + + assertEquals(60L, stream.position()); + } + + @Test + public void testWriteFullyAt_whenFullButPartial_thenSucceed() throws IOException { + SeekableByteChannel channel = mock(SeekableByteChannel.class); + SeekableChannelRandomAccessOutputStream stream = new SeekableChannelRandomAccessOutputStream(channel); + + when(channel.position()) + .thenReturn(50L) + .thenReturn(60L); + when(channel.write((ByteBuffer) any())) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(3); + return 3; + }) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(5); + return 2; + }) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(6); + return 6; + }); + + stream.writeFullyAt("hello".getBytes(StandardCharsets.UTF_8), 20); + stream.writeFullyAt("world\n".getBytes(StandardCharsets.UTF_8), 30); + + verify(channel, times(3)) + .write((ByteBuffer) any()); + verify(channel, times(1)) + .position(eq(50L)); + verify(channel, times(1)) + .position(eq(60L)); + + assertEquals(60L, stream.position()); + } + + @Test + public void testWriteFullyAt_whenPartial_thenFail() throws IOException { + SeekableByteChannel channel = mock(SeekableByteChannel.class); + SeekableChannelRandomAccessOutputStream stream = new SeekableChannelRandomAccessOutputStream(channel); + + when(channel.position()) + .thenReturn(50L); + when(channel.write((ByteBuffer) any())) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(3); + return 3; + }) + .thenAnswer(answer -> { + return 0; + }); + + assertThrows(IOException.class, () -> stream.writeFullyAt("hello".getBytes(StandardCharsets.UTF_8), 20)); + + verify(channel, times(2)) + .write((ByteBuffer) any()); + + assertEquals(50L, stream.position()); + } +} diff --git a/src/test/java/org/apache/commons/compress/archivers/zip/ZipArchiveOutputStreamTest.java b/src/test/java/org/apache/commons/compress/archivers/zip/ZipArchiveOutputStreamTest.java new file mode 100644 index 000000000..0c3fb4899 --- /dev/null +++ b/src/test/java/org/apache/commons/compress/archivers/zip/ZipArchiveOutputStreamTest.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.compress.archivers.zip; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import org.apache.commons.compress.AbstractTempDirTest; +import org.junit.jupiter.api.Test; + + +public class ZipArchiveOutputStreamTest extends AbstractTempDirTest { + + @Test + public void testOutputStreamBasics() throws IOException { + try (ZipArchiveOutputStream stream = new ZipArchiveOutputStream(new ByteArrayOutputStream())) { + assertFalse(stream.isSeekable()); + } + } + + @Test + public void testFileBasics() throws IOException { + try (ZipArchiveOutputStream stream = new ZipArchiveOutputStream(createTempFile())) { + assertTrue(stream.isSeekable()); + } + } +} diff --git a/src/test/java/org/apache/commons/compress/archivers/zip/ZipIoUtilTest.java b/src/test/java/org/apache/commons/compress/archivers/zip/ZipIoUtilTest.java new file mode 100644 index 000000000..7e1eb3d45 --- /dev/null +++ b/src/test/java/org/apache/commons/compress/archivers/zip/ZipIoUtilTest.java @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.commons.compress.archivers.zip; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; + +import org.apache.commons.compress.AbstractTempDirTest; +import org.junit.jupiter.api.Test; + + +public class ZipIoUtilTest extends AbstractTempDirTest { + + @Test + public void testWriteFully_whenFullAtOnce_thenSucceed() throws IOException { + SeekableByteChannel channel = mock(SeekableByteChannel.class); + + when(channel.write((ByteBuffer) any())) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(5); + return 5; + }) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(6); + return 6; + }); + + ZipIoUtil.writeFully(channel, ByteBuffer.wrap("hello".getBytes(StandardCharsets.UTF_8))); + ZipIoUtil.writeFully(channel, ByteBuffer.wrap("world\n".getBytes(StandardCharsets.UTF_8))); + + verify(channel, times(2)) + .write((ByteBuffer) any()); + } + + @Test + public void testWriteFully_whenFullButPartial_thenSucceed() throws IOException { + SeekableByteChannel channel = mock(SeekableByteChannel.class); + + when(channel.write((ByteBuffer) any())) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(3); + return 3; + }) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(5); + return 2; + }) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(6); + return 6; + }); + + ZipIoUtil.writeFully(channel, ByteBuffer.wrap("hello".getBytes(StandardCharsets.UTF_8))); + ZipIoUtil.writeFully(channel, ByteBuffer.wrap("world\n".getBytes(StandardCharsets.UTF_8))); + + verify(channel, times(3)) + .write((ByteBuffer) any()); + } + + @Test + public void testWriteFully_whenPartial_thenFail() throws IOException { + SeekableByteChannel channel = mock(SeekableByteChannel.class); + + when(channel.write((ByteBuffer) any())) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(3); + return 3; + }) + .thenAnswer(answer -> { + return 0; + }); + + assertThrows(IOException.class, () -> + ZipIoUtil.writeFully(channel, ByteBuffer.wrap("hello".getBytes(StandardCharsets.UTF_8))) + ); + + verify(channel, times(2)) + .write((ByteBuffer) any()); + } + + @Test + public void testWriteFullyAt_whenFullAtOnce_thenSucceed() throws IOException { + FileChannel channel = mock(FileChannel.class); + + when(channel.write((ByteBuffer) any(), eq(20L))) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(5); + return 5; + }); + when(channel.write((ByteBuffer) any(), eq(30L))) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(6); + return 6; + }); + + ZipIoUtil.writeFullyAt(channel, ByteBuffer.wrap("hello".getBytes(StandardCharsets.UTF_8)), 20); + ZipIoUtil.writeFullyAt(channel, ByteBuffer.wrap("world\n".getBytes(StandardCharsets.UTF_8)), 30); + + verify(channel, times(1)) + .write((ByteBuffer) any(), eq(20L)); + verify(channel, times(1)) + .write((ByteBuffer) any(), eq(30L)); + } + + @Test + public void testWriteFullyAt_whenFullButPartial_thenSucceed() throws IOException { + FileChannel channel = mock(FileChannel.class); + + when(channel.write((ByteBuffer) any(), eq(20L))) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(3); + return 3; + }); + when(channel.write((ByteBuffer) any(), eq(23L))) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(5); + return 2; + }); + when(channel.write((ByteBuffer) any(), eq(30L))) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(6); + return 6; + }); + + ZipIoUtil.writeFullyAt(channel, ByteBuffer.wrap("hello".getBytes(StandardCharsets.UTF_8)), 20); + ZipIoUtil.writeFullyAt(channel, ByteBuffer.wrap("world\n".getBytes(StandardCharsets.UTF_8)), 30); + + verify(channel, times(1)) + .write((ByteBuffer) any(), eq(20L)); + verify(channel, times(1)) + .write((ByteBuffer) any(), eq(23L)); + verify(channel, times(1)) + .write((ByteBuffer) any(), eq(30L)); + } + + @Test + public void testWriteFullyAt_whenPartial_thenFail() throws IOException { + FileChannel channel = mock(FileChannel.class); + + when(channel.write((ByteBuffer) any(), eq(20L))) + .thenAnswer(answer -> { + ((ByteBuffer) answer.getArgument(0)).position(3); + return 3; + }); + when(channel.write((ByteBuffer) any(), eq(23L))) + .thenAnswer(answer -> { + return 0; + }); + assertThrows(IOException.class, () -> + ZipIoUtil.writeFullyAt(channel, ByteBuffer.wrap("hello".getBytes(StandardCharsets.UTF_8)), 20)); + + verify(channel, times(1)) + .write((ByteBuffer) any(), eq(20L)); + verify(channel, times(1)) + .write((ByteBuffer) any(), eq(23L)); + verify(channel, times(0)) + .write((ByteBuffer) any(), eq(25L)); + } +} diff --git a/src/test/java/org/apache/commons/compress/utils/ZipSplitReadOnlySeekableByteChannelTest.java b/src/test/java/org/apache/commons/compress/utils/ZipSplitReadOnlySeekableByteChannelTest.java index cc8b1e4a0..fddc1f26f 100644 --- a/src/test/java/org/apache/commons/compress/utils/ZipSplitReadOnlySeekableByteChannelTest.java +++ b/src/test/java/org/apache/commons/compress/utils/ZipSplitReadOnlySeekableByteChannelTest.java @@ -156,7 +156,7 @@ public class ZipSplitReadOnlySeekableByteChannelTest { @Test public void testForPathsOfTwoParametersThrowsOnNullArg() { - assertThrows(NullPointerException.class, () -> ZipSplitReadOnlySeekableByteChannel.forPaths(null, null)); + assertThrows(NullPointerException.class, () -> ZipSplitReadOnlySeekableByteChannel.forPaths((Path) null, null)); } @Test