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 &quot;Zip64 end of 
central directory locator&quot;'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 &quot;End 
of central dir record&quot;.
+     *
+     * @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

Reply via email to