diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/BZip2CompressorConfig.java b/src/main/java/org/apache/commons/compress/archivers/zip/BZip2CompressorConfig.java new file mode 100644 index 00000000000..f856bd1b246 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/zip/BZip2CompressorConfig.java @@ -0,0 +1,54 @@ +/* + * 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 + * + * https://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; + +/** + * Configuration for BZIP2 compression in ZIP archives. + * + * @since 1.29.0 + */ +public class BZip2CompressorConfig implements CompressorConfig { + + private final int blockSize; + + /** + * Creates a default BZIP2 configuration (blockSize 9). + */ + public BZip2CompressorConfig() { + this(9); + } + + /** + * Creates a BZIP2 configuration with the specified block size. + * + * @param blockSize the block size (1-9) + */ + public BZip2CompressorConfig(final int blockSize) { + this.blockSize = blockSize; + } + + /** + * Gets the block size. + * + * @return the block size + */ + public int getBlockSize() { + return blockSize; + } +} diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/BZip2ZipCompressorStreamFactory.java b/src/main/java/org/apache/commons/compress/archivers/zip/BZip2ZipCompressorStreamFactory.java new file mode 100644 index 00000000000..94116df99e5 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/zip/BZip2ZipCompressorStreamFactory.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 + * + * https://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; + +import org.apache.commons.compress.compressors.CompressorOutputStream; +import org.apache.commons.compress.compressors.bzip2.BZip2CompressorOutputStream; + +/** + * Factory for creating BZIP2 compressor output streams in ZIP archives. + * + * @since 1.29.0 + */ +public class BZip2ZipCompressorStreamFactory implements ZipCompressorStreamFactory { + + @Override + public CompressorOutputStream createCompressorOutputStream(final OutputStream out, final CompressorConfig config) throws IOException { + final BZip2CompressorConfig bzip2Config = (BZip2CompressorConfig) config; + return new BZip2CompressorOutputStream(out, bzip2Config.getBlockSize()); + } + + @Override + public CompressorConfig defaultConfig() { + return new BZip2CompressorConfig(); + } +} diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/CompressorConfig.java b/src/main/java/org/apache/commons/compress/archivers/zip/CompressorConfig.java new file mode 100644 index 00000000000..b97325a5166 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/zip/CompressorConfig.java @@ -0,0 +1,29 @@ +/* + * 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 + * + * https://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; + +/** + * Marker interface for compressor-specific configuration. + * Implementations carry parameters for a specific compression method + * (e.g., compression level, block size). + * + * @since 1.29.0 + */ +public interface CompressorConfig { +} diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/StreamCompressor.java b/src/main/java/org/apache/commons/compress/archivers/zip/StreamCompressor.java index 0b8c609dc70..76f817dc916 100644 --- a/src/main/java/org/apache/commons/compress/archivers/zip/StreamCompressor.java +++ b/src/main/java/org/apache/commons/compress/archivers/zip/StreamCompressor.java @@ -273,6 +273,30 @@ void reset() { writtenToOutputStreamForLastEntry = 0; } + /** + * Updates the CRC checksum without writing data. + * + * @param b the byte array + * @param offset start offset + * @param length number of bytes + * + * @since 1.29.0 + */ + void updateCrc(final byte[] b, final int offset, final int length) { + crc.update(b, offset, length); + } + + /** + * Updates the source payload length counter. + * + * @param length number of uncompressed bytes + * + * @since 1.29.0 + */ + void updateSourcePayloadLength(final int length) { + sourcePayloadLength += length; + } + /** * Writes bytes to ZIP entry. * diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/XZCompressorConfig.java b/src/main/java/org/apache/commons/compress/archivers/zip/XZCompressorConfig.java new file mode 100644 index 00000000000..0aa0d7ec302 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/zip/XZCompressorConfig.java @@ -0,0 +1,54 @@ +/* + * 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 + * + * https://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; + +/** + * Configuration for XZ compression in ZIP archives. + * + * @since 1.29.0 + */ +public class XZCompressorConfig implements CompressorConfig { + + private final int preset; + + /** + * Creates a default XZ configuration (preset 6). + */ + public XZCompressorConfig() { + this(6); + } + + /** + * Creates an XZ configuration with the specified preset. + * + * @param preset the LZMA2 preset level (0-9) + */ + public XZCompressorConfig(final int preset) { + this.preset = preset; + } + + /** + * Gets the preset level. + * + * @return the preset level + */ + public int getPreset() { + return preset; + } +} diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/XZZipCompressorStreamFactory.java b/src/main/java/org/apache/commons/compress/archivers/zip/XZZipCompressorStreamFactory.java new file mode 100644 index 00000000000..b4e559834e2 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/zip/XZZipCompressorStreamFactory.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 + * + * https://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; + +import org.apache.commons.compress.compressors.CompressorOutputStream; +import org.apache.commons.compress.compressors.xz.XZCompressorOutputStream; + +/** + * Factory for creating XZ compressor output streams in ZIP archives. + * + * @since 1.29.0 + */ +public class XZZipCompressorStreamFactory implements ZipCompressorStreamFactory { + + @Override + public CompressorOutputStream createCompressorOutputStream(final OutputStream out, final CompressorConfig config) throws IOException { + final XZCompressorConfig xzConfig = (XZCompressorConfig) config; + return new XZCompressorOutputStream(out, xzConfig.getPreset()); + } + + @Override + public CompressorConfig defaultConfig() { + return new XZCompressorConfig(); + } +} diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/ZipArchiveEntry.java b/src/main/java/org/apache/commons/compress/archivers/zip/ZipArchiveEntry.java index cff1325cfef..cf1eb596e03 100644 --- a/src/main/java/org/apache/commons/compress/archivers/zip/ZipArchiveEntry.java +++ b/src/main/java/org/apache/commons/compress/archivers/zip/ZipArchiveEntry.java @@ -320,6 +320,8 @@ private static String toEntryName(final Path inputPath, final String entryName, private long time = -1; + private CompressorConfig compressorConfig; + /** * Constructs a new instance with an empty name. */ @@ -854,6 +856,16 @@ public int getMethod() { return method; } + /** + * Gets the compressor configuration for this entry. + * + * @return the compressor config, or {@code null} if not set + * @since 1.29.0 + */ + public CompressorConfig getCompressorConfig() { + return compressorConfig; + } + /** * Gets the name of the entry. * @@ -1397,6 +1409,18 @@ public void setMethod(final int method) { this.method = method; } + /** + * Sets the compressor configuration for this entry. + * When used with {@link ZipArchiveOutputStream} in auto-compress mode, + * this configuration overrides the factory's default. + * + * @param compressorConfig the compressor config, or {@code null} to use the factory default + * @since 1.29.0 + */ + public void setCompressorConfig(final CompressorConfig compressorConfig) { + this.compressorConfig = compressorConfig; + } + /** * Sets the name of the entry. * 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 e5add710ea1..d8ef84a8cf5 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 @@ -37,11 +37,16 @@ import java.util.zip.Deflater; import java.util.zip.ZipException; +import org.apache.commons.compress.archivers.AbstractArchiveBuilder; import org.apache.commons.compress.archivers.ArchiveEntry; import org.apache.commons.compress.archivers.ArchiveException; import org.apache.commons.compress.archivers.ArchiveOutputStream; +import org.apache.commons.compress.compressors.CompressorOutputStream; import org.apache.commons.io.Charsets; import org.apache.commons.io.IOUtils; +import org.apache.commons.io.build.AbstractOrigin; +import org.apache.commons.io.build.AbstractOrigin.ChannelOrigin; +import org.apache.commons.io.build.AbstractOrigin.OutputStreamOrigin; import org.apache.commons.lang3.ArrayUtils; /** @@ -66,6 +71,111 @@ */ public class ZipArchiveOutputStream extends ArchiveOutputStream { + /** + * Abstract builder for derived classes of {@link ZipArchiveOutputStream}. + * + * @param The type of the {@link ZipArchiveOutputStream}. + * @param The type of the builder itself. + * @since 1.29.0 + */ + public abstract static class AbstractBuilder> + extends AbstractArchiveBuilder { + + /** + * Maximum size of a single part of the split archive created by this stream. Must be between 64kB and about 4GB. + */ + protected long zipSplitSize; + + /** + * Whether this stream automatically compresses entries using registered compressor factories. + */ + protected boolean autoCompress; + + /** + * Constructs a new instance. + */ + protected AbstractBuilder() { + setCharset(StandardCharsets.UTF_8); + } + + /** + * Sets the maximum size of a single part of the split archive created by this stream. Must be between 64kB and about 4GB. + * + *

Zero by default that means there is no splitting.

+ * + * @param zipSplitSize the size of a single split part. + * @return {@code this} instance. + */ + public B setZipSplitSize(long zipSplitSize) { + this.zipSplitSize = zipSplitSize; + return asThis(); + } + + /** + * Sets whether this stream automatically compresses entries using registered compressor factories. + * + *

Disabled by default.

+ * + * @param autoCompress {@code true} to automatically compress entries, {@code false} otherwise. + * @return {@code this} instance. + */ + public B setAutoCompress(final boolean autoCompress) { + this.autoCompress = autoCompress; + return asThis(); + } + + @Override + protected AbstractOrigin getOrigin() { + return super.getOrigin(); + } + } + + /** + * Builds a new {@link ZipArchiveOutputStream}. + *

+ * For example: + *

+ *
{@code
+     * ZipArchiveOutputStream in = ZipArchiveOutputStream.builder()
+     *     .setPath(inputPath)
+     *     .setCharset(StandardCharsets.UTF_8)
+     *     .setAutoCompress(true)
+     *     .get();
+     * }
+ * + * @since 1.29.0 + */ + public static final class Builder extends AbstractBuilder { + + private Builder() { + // empty + } + + @Override + public ZipArchiveOutputStream get() throws IOException { + return new ZipArchiveOutputStream(this); + } + } + + /** + * Bridge stream that receives compressed data from a CompressorOutputStream + * and writes it to the underlying StreamCompressor as raw (already-compressed) bytes. + */ + private class CompressorBridgeOutputStream extends OutputStream { + private final byte[] oneByte = new byte[1]; + + @Override + public void write(final int b) throws IOException { + oneByte[0] = (byte) (b & ZipConstants.BYTE_MASK); + write(oneByte, 0, 1); + } + + @Override + public void write(final byte[] b, final int off, final int len) throws IOException { + streamCompressor.writeCounted(b, off, len); + } + } + /** * Structure collecting information for the entry that is currently being written. */ @@ -92,7 +202,7 @@ private static final class CurrentEntry { private long bytesRead; /** - * Whether current entry was the first one using ZIP64 features. + * Whether the current entry was the first one using ZIP64 features. */ private boolean causedUseOfZip64; @@ -260,6 +370,16 @@ public String toString() { */ static final byte[] ZIP64_EOCD_LOC_SIG = ZipLong.getBytes(0X07064B50L); // NOSONAR + /** + * Creates a new builder. + * + * @return A new builder. + * @since 1.29.0 + */ + public static Builder builder() { + return new Builder(); + } + /** * Indicates if this archive is finished. protected for use in Jar implementation. * @@ -378,11 +498,64 @@ public String toString() { */ private final boolean isSplitZip; + /** + * Whether this stream automatically compresses entries using registered compressor factories. + * + * @since 1.29.0 + */ + private final boolean autoCompress; + + /** + * Registry of compressor factories keyed by ZIP method code. + */ + private Map compressorFactories; + + /** + * The active compressor output stream wrapping the current entry's data, or {@code null}. + */ + private CompressorOutputStream activeCompressorStream; + /** * Holds the number of Central Directories on each disk. This is used when writing Zip64 End Of Central Directory and End Of Central Directory. */ private final Map numberOfCDInDiskData = new HashMap<>(); + /** + * Creates an instance from the given builder. + * + * @param builder The builder used to configure and create the stream. + * @throws IOException If the builder fails to create the underlying {@link OutputStream}. + * @since 1.29.0 + */ + protected ZipArchiveOutputStream(final AbstractBuilder builder) throws IOException { + final AbstractOrigin origin = builder.getOrigin(); + if (origin instanceof OutputStreamOrigin) { + this.out = builder.getOutputStream(); + this.isSplitZip = false; + } else if (origin instanceof ChannelOrigin) { + this.out = new SeekableChannelRandomAccessOutputStream(builder.getChannel(SeekableByteChannel.class)); + this.isSplitZip = false; + } else if (origin != null) { + final Path path = builder.getPath(); + if (builder.zipSplitSize > 0) { + this.out = new ZipSplitOutputStream(path, builder.zipSplitSize); + this.isSplitZip = true; + } else { + final OpenOption[] options = builder.getOpenOptions(); + this.out = options.length == 0 ? new FileRandomAccessOutputStream(path) : new FileRandomAccessOutputStream(path, options); + this.isSplitZip = false; + } + } else { + throw new IOException("No output stream available"); + } + this.def = new Deflater(level, true); + this.streamCompressor = StreamCompressor.create(this.out, def); + this.autoCompress = builder.autoCompress; + if (autoCompress) { + registerDefaultCompressorFactories(); + } + } + /** * Creates a new ZIP OutputStream writing to a File. Will use random access if possible. * @@ -411,7 +584,9 @@ public ZipArchiveOutputStream(final File file) throws IOException { * @throws IOException on error. * @throws IllegalArgumentException if zipSplitSize is not in the required range. * @since 1.20 + * @deprecated Since 1.29.0, use {@link #builder()}. */ + @Deprecated public ZipArchiveOutputStream(final File file, final long zipSplitSize) throws IOException { this(file.toPath(), zipSplitSize); } @@ -426,6 +601,7 @@ public ZipArchiveOutputStream(final OutputStream out) { this.def = new Deflater(level, true); this.streamCompressor = StreamCompressor.create(out, def); this.isSplitZip = false; + this.autoCompress = false; } /** @@ -444,12 +620,13 @@ public ZipArchiveOutputStream(final OutputStream out) { * @throws IOException on error. * @throws IllegalArgumentException if zipSplitSize is not in the required range. * @since 1.22 + * @deprecated Since 1.29.0, use {@link #builder()}. */ + @Deprecated public ZipArchiveOutputStream(final Path path, final long zipSplitSize) throws IOException { - this.def = new Deflater(level, true); - this.out = new ZipSplitOutputStream(path, zipSplitSize); - this.streamCompressor = StreamCompressor.create(this.out, def); - this.isSplitZip = true; + this(builder() + .setPath(path) + .setZipSplitSize(zipSplitSize)); } /** @@ -461,10 +638,9 @@ public ZipArchiveOutputStream(final Path path, final long zipSplitSize) throws I * @since 1.21 */ public ZipArchiveOutputStream(final Path file, final OpenOption... options) throws IOException { - this.def = new Deflater(level, true); - this.out = options.length == 0 ? new FileRandomAccessOutputStream(file) : new FileRandomAccessOutputStream(file, options); - this.streamCompressor = StreamCompressor.create(out, def); - this.isSplitZip = false; + this(builder() + .setPath(file) + .setOpenOptions(options)); } /** @@ -482,6 +658,7 @@ public ZipArchiveOutputStream(final SeekableByteChannel channel) { this.def = new Deflater(level, true); this.streamCompressor = StreamCompressor.create(out, def); this.isSplitZip = false; + this.autoCompress = false; } /** @@ -589,6 +766,12 @@ public void close() throws IOException { public void closeArchiveEntry() throws IOException { preClose(); + if (activeCompressorStream != null) { + activeCompressorStream.flush(); + activeCompressorStream.close(); + activeCompressorStream = null; + } + flushDeflater(); final long bytesWritten = streamCompressor.getTotalBytesWritten() - entry.dataStart; @@ -853,7 +1036,8 @@ private byte[] createLocalFileHeader(final ZipArchiveEntry ze, final ByteBuffer ZipUtil.toDosTime(ze.getTime(), buf, LFH_TIME_OFFSET); // CRC - if (phased || !(zipMethod == DEFLATED || out instanceof RandomAccessOutputStream)) { + final boolean isAutoCompressed = autoCompress && compressorFactories.containsKey(zipMethod); + if (phased || !(zipMethod == DEFLATED || isAutoCompressed || out instanceof RandomAccessOutputStream)) { ZipLong.putLong(ze.getCrc(), buf, LFH_CRC_OFFSET); } else { System.arraycopy(LZERO, 0, buf, LFH_CRC_OFFSET, ZipConstants.WORD); @@ -870,7 +1054,7 @@ private byte[] createLocalFileHeader(final ZipArchiveEntry ze, final ByteBuffer } 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 || out instanceof RandomAccessOutputStream) { + } else if (zipMethod == DEFLATED || isAutoCompressed || out 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 if (ZipMethod.isZstd(zipMethod) || zipMethod == ZipMethod.XZ.getCode()) { @@ -929,7 +1113,7 @@ public void finish() throws IOException { } if (entry != null) { - throw new ArchiveException("This archive contains unclosed entries."); + closeArchiveEntry(); } final long cdOverallOffset = streamCompressor.getTotalBytesWritten(); @@ -1079,6 +1263,10 @@ private boolean handleSizesAndCrc(final long bytesWritten, final long crc, final entry.entry.setSize(entry.bytesRead); entry.entry.setCompressedSize(bytesWritten); entry.entry.setCrc(crc); + } else if (autoCompress && compressorFactories.containsKey(zipMethod)) { + entry.entry.setSize(entry.bytesRead); + entry.entry.setCompressedSize(bytesWritten); + entry.entry.setCrc(crc); } else if (ZipMethod.isZstd(zipMethod) || zipMethod == ZipMethod.XZ.getCode()) { entry.entry.setCompressedSize(bytesWritten); entry.entry.setCrc(crc); @@ -1239,6 +1427,16 @@ private void putArchiveEntry(final ZipArchiveEntry archiveEntry, final boolean p hasCompressionLevelChanged = false; } writeLocalFileHeader(archiveEntry, phased); + + if (autoCompress && entry.entry.getMethod() != DEFLATED && entry.entry.getMethod() != STORED) { + final ZipCompressorStreamFactory factory = compressorFactories.get(entry.entry.getMethod()); + if (factory != null) { + final CompressorConfig config = entry.entry.getCompressorConfig() != null + ? entry.entry.getCompressorConfig() + : factory.defaultConfig(); + activeCompressorStream = factory.createCompressorOutputStream(new CompressorBridgeOutputStream(), config); + } + } } /** @@ -1440,6 +1638,29 @@ public void setUseZip64(final Zip64Mode mode) { zip64Mode = mode; } + /** + * Registers a compressor factory for a specific ZIP compression method. + * Only effective when {@code autoCompress} is enabled. + * + * @param method the ZIP method to associate with this factory + * @param factory the factory to create compressor output streams + * @since 1.29.0 + */ + public void registerCompressorFactory(final ZipMethod method, final ZipCompressorStreamFactory factory) { + if (autoCompress) { + compressorFactories.put(method.getCode(), factory); + } + } + + private void registerDefaultCompressorFactories() { + compressorFactories = new HashMap<>(); + final ZstdZipCompressorStreamFactory zstdFactory = new ZstdZipCompressorStreamFactory(); + compressorFactories.put(ZipMethod.ZSTD.getCode(), zstdFactory); + compressorFactories.put(ZipMethod.ZSTD_DEPRECATED.getCode(), zstdFactory); + compressorFactories.put(ZipMethod.BZIP2.getCode(), new BZip2ZipCompressorStreamFactory()); + compressorFactories.put(ZipMethod.XZ.getCode(), new XZZipCompressorStreamFactory()); + } + /** * Tests whether to add a Zip64 extended information extra field to the local file header. *

@@ -1483,7 +1704,8 @@ private boolean shouldUseZip64EOCD() { } private boolean usesDataDescriptor(final int zipMethod, final boolean phased) { - return !phased && zipMethod == DEFLATED && !(out instanceof RandomAccessOutputStream); + return !phased && (zipMethod == DEFLATED || autoCompress && compressorFactories.containsKey(zipMethod)) + && !(out instanceof RandomAccessOutputStream); } /** @@ -1582,8 +1804,14 @@ public void write(final byte[] b, final int offset, final int length) throws IOE throw new IllegalStateException("No current entry"); } ZipUtil.checkRequestedFeatures(entry.entry); - final long writtenThisTime = streamCompressor.write(b, offset, length, entry.entry.getMethod()); - count(writtenThisTime); + if (activeCompressorStream != null) { + streamCompressor.updateCrc(b, offset, length); + streamCompressor.updateSourcePayloadLength(length); + activeCompressorStream.write(b, offset, length); + } else { + final long writtenThisTime = streamCompressor.write(b, offset, length, entry.entry.getMethod()); + count(writtenThisTime); + } } /** diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/ZipCompressorStreamFactory.java b/src/main/java/org/apache/commons/compress/archivers/zip/ZipCompressorStreamFactory.java new file mode 100644 index 00000000000..5445ad9b2ba --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/zip/ZipCompressorStreamFactory.java @@ -0,0 +1,50 @@ +/* + * 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 + * + * https://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; + +import org.apache.commons.compress.compressors.CompressorOutputStream; + +/** + * Factory that creates a {@link CompressorOutputStream} for use in + * {@link ZipArchiveOutputStream} auto-compression mode. + * + * @since 1.29.0 + */ +public interface ZipCompressorStreamFactory { + + /** + * Creates a compressor output stream wrapping the given output stream. + * + * @param out the underlying output stream (writes to the ZIP entry data) + * @param config compressor-specific configuration, never {@code null} + * @return a new compressor output stream + * @throws IOException if the compressor stream cannot be created + */ + CompressorOutputStream createCompressorOutputStream(OutputStream out, CompressorConfig config) throws IOException; + + /** + * Returns the default configuration for this compressor. + * + * @return a default config instance, never {@code null} + */ + CompressorConfig defaultConfig(); +} diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/ZstdCompressorConfig.java b/src/main/java/org/apache/commons/compress/archivers/zip/ZstdCompressorConfig.java new file mode 100644 index 00000000000..9522e26c636 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/zip/ZstdCompressorConfig.java @@ -0,0 +1,66 @@ +/* + * 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 + * + * https://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; + +/** + * Configuration for ZSTD compression in ZIP archives. + * + * @since 1.29.0 + */ +public class ZstdCompressorConfig implements CompressorConfig { + + private final int level; + private final boolean closeFrameOnFlush; + + /** + * Creates a default ZSTD configuration (level 3, closeFrameOnFlush true). + */ + public ZstdCompressorConfig() { + this(3, true); + } + + /** + * Creates a ZSTD configuration with the specified parameters. + * + * @param level the compression level + * @param closeFrameOnFlush whether to close the frame on flush + */ + public ZstdCompressorConfig(final int level, final boolean closeFrameOnFlush) { + this.level = level; + this.closeFrameOnFlush = closeFrameOnFlush; + } + + /** + * Gets the compression level. + * + * @return the compression level + */ + public int getLevel() { + return level; + } + + /** + * Gets whether to close the frame on flush. + * + * @return whether to close the frame on flush + */ + public boolean isCloseFrameOnFlush() { + return closeFrameOnFlush; + } +} diff --git a/src/main/java/org/apache/commons/compress/archivers/zip/ZstdZipCompressorStreamFactory.java b/src/main/java/org/apache/commons/compress/archivers/zip/ZstdZipCompressorStreamFactory.java new file mode 100644 index 00000000000..ef822439967 --- /dev/null +++ b/src/main/java/org/apache/commons/compress/archivers/zip/ZstdZipCompressorStreamFactory.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 + * + * https://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; + +import org.apache.commons.compress.compressors.CompressorOutputStream; +import org.apache.commons.compress.compressors.zstandard.ZstdCompressorOutputStream; + +/** + * Factory for creating ZSTD compressor output streams in ZIP archives. + * + * @since 1.29.0 + */ +public class ZstdZipCompressorStreamFactory implements ZipCompressorStreamFactory { + + @Override + public CompressorOutputStream createCompressorOutputStream(final OutputStream out, final CompressorConfig config) throws IOException { + final ZstdCompressorConfig zstdConfig = (ZstdCompressorConfig) config; + return new ZstdCompressorOutputStream(out, zstdConfig.getLevel(), zstdConfig.isCloseFrameOnFlush()); + } + + @Override + public CompressorConfig defaultConfig() { + return new ZstdCompressorConfig(); + } +} diff --git a/src/test/java/org/apache/commons/compress/archivers/ArchiveOutputStreamTest.java b/src/test/java/org/apache/commons/compress/archivers/ArchiveOutputStreamTest.java index 921c2600071..4306d264600 100644 --- a/src/test/java/org/apache/commons/compress/archivers/ArchiveOutputStreamTest.java +++ b/src/test/java/org/apache/commons/compress/archivers/ArchiveOutputStreamTest.java @@ -61,10 +61,15 @@ private void doCallSequence(final String archiveType) throws Exception { aos2.putArchiveEntry(aos2.createArchiveEntry(dummy, "dummy")); aos2.write(dummy); - // TODO check if second putArchiveEntry() can follow without closeAE? - - assertThrows(ArchiveException.class, aos2::finish, "Should have raised IOException - finish() called before closeArchiveEntry()"); - assertThrows(ArchiveException.class, aos2::close, "Should have raised IOException - close() called before closeArchiveEntry()"); + // Zip/Jar implicitly close the current entry on finish()/close(), + // other archive types throw an exception. + if ("Zip".equals(archiveType) || "Jar".equals(archiveType)) { + aos2.finish(); + aos2.close(); + } else { + assertThrows(ArchiveException.class, aos2::finish, "Should have raised IOException - finish() called before closeArchiveEntry()"); + assertThrows(ArchiveException.class, aos2::close, "Should have raised IOException - close() called before closeArchiveEntry()"); + } final O aos3 = createArchiveWithDummyEntry(archiveType, out1, dummy); aos3.closeArchiveEntry(); @@ -106,16 +111,15 @@ void testCallSequenceZip() throws Exception { void testFinish() throws Exception { final OutputStream out1 = new ByteArrayOutputStream(); - try (ArchiveOutputStream aios = factory.createArchiveOutputStream("zip", out1)) { + // Zip and Jar implicitly close the current entry on finish() + try (ArchiveOutputStream aios = factory.createArchiveOutputStream("zip", out1)) { aios.putArchiveEntry(new ZipArchiveEntry("dummy")); - assertThrows(ArchiveException.class, () -> aios.finish(), "After putArchiveEntry() should follow closeArchiveEntry()"); - aios.closeArchiveEntry(); + aios.finish(); } try (ArchiveOutputStream aios = factory.createArchiveOutputStream("jar", out1)) { aios.putArchiveEntry(new JarArchiveEntry("dummy")); - assertThrows(ArchiveException.class, () -> aios.finish(), "After putArchiveEntry() should follow closeArchiveEntry()"); - aios.closeArchiveEntry(); + aios.finish(); } try (ArchiveOutputStream aios = factory.createArchiveOutputStream("ar", out1)) { diff --git a/src/test/java/org/apache/commons/compress/archivers/zip/ZipArchiveEntryCompressorConfigTest.java b/src/test/java/org/apache/commons/compress/archivers/zip/ZipArchiveEntryCompressorConfigTest.java new file mode 100644 index 00000000000..54d2fcd0e5d --- /dev/null +++ b/src/test/java/org/apache/commons/compress/archivers/zip/ZipArchiveEntryCompressorConfigTest.java @@ -0,0 +1,41 @@ +/* + * 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 + * + * https://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.assertNull; + +import org.junit.jupiter.api.Test; + +class ZipArchiveEntryCompressorConfigTest { + + @Test + void testCompressorConfigDefaultIsNull() { + ZipArchiveEntry entry = new ZipArchiveEntry("test.txt"); + assertNull(entry.getCompressorConfig()); + } + + @Test + void testSetAndGetCompressorConfig() { + ZipArchiveEntry entry = new ZipArchiveEntry("test.txt"); + ZstdCompressorConfig config = new ZstdCompressorConfig(6, false); + entry.setCompressorConfig(config); + assertEquals(config, entry.getCompressorConfig()); + } +} 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 index c576cd4fd07..7ebd50e0096 100644 --- a/src/test/java/org/apache/commons/compress/archivers/zip/ZipArchiveOutputStreamTest.java +++ b/src/test/java/org/apache/commons/compress/archivers/zip/ZipArchiveOutputStreamTest.java @@ -20,12 +20,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.channels.NonWritableChannelException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.nio.file.StandardOpenOption; import java.util.zip.Deflater; import java.util.zip.ZipEntry; @@ -77,4 +80,20 @@ void testSetEncoding() throws IOException { assertEquals(Charset.defaultCharset().name(), outputStream.getEncoding()); } } + + @Test + void testOpenFileWithOptions() throws IOException { + ZipArchiveOutputStream out = ZipArchiveOutputStream.builder() + .setFile(createTempFile()) + .setOpenOptions(StandardOpenOption.READ, StandardOpenOption.DELETE_ON_CLOSE) + .get(); + ZipArchiveEntry entry = new ZipArchiveEntry("test.txt"); + assertThrows(NonWritableChannelException.class, () -> out.putArchiveEntry(entry)); + } + + @Test + void testReportMissingOutputStreamUsingBuilder() { + IOException exception = assertThrows(IOException.class, () -> ZipArchiveOutputStream.builder().get()); + assertEquals("No output stream available", exception.getMessage()); + } } diff --git a/src/test/java/org/apache/commons/compress/archivers/zip/ZipAutoCompressTest.java b/src/test/java/org/apache/commons/compress/archivers/zip/ZipAutoCompressTest.java new file mode 100644 index 00000000000..d2ab7e9a92b --- /dev/null +++ b/src/test/java/org/apache/commons/compress/archivers/zip/ZipAutoCompressTest.java @@ -0,0 +1,171 @@ +/* + * 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 + * + * https://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.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.file.Files; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; + +import org.apache.commons.compress.AbstractTest; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class ZipAutoCompressTest extends AbstractTest { + + private static final byte[] TEST_DATA_1 = "This is test data for auto-compress.\nThis is a second line.".getBytes(); + private static final byte[] TEST_DATA_2 = "The second entry for auto-compress.\nAnother line.".getBytes(); + + @ParameterizedTest + @EnumSource(names = {"BZIP2", "XZ", "ZSTD", "ZSTD_DEPRECATED"}) + void testAutoCompressZstd(ZipMethod zipMethod) throws Exception { + File file = new File(getTempDirFile(), "autocompress.zip"); + try (ZipArchiveOutputStream out = ZipArchiveOutputStream.builder() + .setFile(file) + .setAutoCompress(true) + .get()) { + ZipArchiveEntry entry1 = new ZipArchiveEntry("test1.txt"); + entry1.setMethod(zipMethod.getCode()); + out.putArchiveEntry(entry1); + out.write(TEST_DATA_1); + ZipArchiveEntry entry2 = new ZipArchiveEntry("test2.txt"); + entry2.setMethod(zipMethod.getCode()); + out.putArchiveEntry(entry2); + out.write(TEST_DATA_2); + } + assertZipFile(file, zipMethod); + } + + @Test + void testAutoCompressZstdWithCustomConfig() throws Exception { + File file = new File(getTempDirFile(), "autocompress.zip"); + try (ZipArchiveOutputStream out = ZipArchiveOutputStream.builder() + .setOutputStream(Files.newOutputStream(file.toPath())) + .setAutoCompress(true) + .get()) { + ZipArchiveEntry entry1 = new ZipArchiveEntry("test1.txt"); + entry1.setMethod(ZipMethod.ZSTD.getCode()); + entry1.setCompressorConfig(new ZstdCompressorConfig(6, true)); + out.putArchiveEntry(entry1); + out.write(TEST_DATA_1); + ZipArchiveEntry entry2 = new ZipArchiveEntry("test2.txt"); + entry2.setMethod(ZipMethod.ZSTD.getCode()); + entry2.setCompressorConfig(new ZstdCompressorConfig(2, true)); + out.putArchiveEntry(entry2); + out.write(TEST_DATA_2); + } + assertZipFile(file, ZipMethod.ZSTD); + } + + @Test + void testAutoCompressDeflatedStillWorks() throws Exception { + File file = new File(getTempDirFile(), "autocompress.zip"); + try (ZipArchiveOutputStream out = ZipArchiveOutputStream.builder() + .setPath(file.toPath()) + .setAutoCompress(true) + .get()) { + ZipArchiveEntry entry1 = new ZipArchiveEntry("test1.txt"); + entry1.setMethod(ZipEntry.DEFLATED); + out.putArchiveEntry(entry1); + out.write(TEST_DATA_1); + ZipArchiveEntry entry2 = new ZipArchiveEntry("test2.txt"); + entry2.setMethod(ZipEntry.DEFLATED); + out.putArchiveEntry(entry2); + out.write(TEST_DATA_2); + } + assertZipFile(file, ZipMethod.DEFLATED); + } + + @Test + void testAutoCompressStoredStillWorks() throws Exception { + File file = new File(getTempDirFile(), "autocompress.zip"); + try (FileOutputStream fos = new FileOutputStream(file); + ZipArchiveOutputStream out = ZipArchiveOutputStream.builder() + .setChannel(fos.getChannel()) + .setAutoCompress(true) + .get()) { + ZipArchiveEntry entry1 = new ZipArchiveEntry("test1.txt"); + entry1.setMethod(ZipEntry.STORED); + entry1.setSize(TEST_DATA_1.length); + entry1.setCrc(getCrc(TEST_DATA_1)); + out.putArchiveEntry(entry1); + out.write(TEST_DATA_1); + ZipArchiveEntry entry2 = new ZipArchiveEntry("test2.txt"); + entry2.setMethod(ZipEntry.STORED); + entry2.setSize(TEST_DATA_2.length); + entry2.setCrc(getCrc(TEST_DATA_2)); + out.putArchiveEntry(entry2); + out.write(TEST_DATA_2); + } + assertZipFile(file, ZipMethod.STORED); + } + + private static long getCrc(byte[] data) { + CRC32 crc = new CRC32(); + crc.update(data, 0, data.length); + return crc.getValue(); + } + + @Test + void testAutoCompressFalsePreservesLegacyBehavior() throws Exception { + File file = new File(getTempDirFile(), "autocompress.zip"); + try (ZipArchiveOutputStream zos = new ZipArchiveOutputStream(file)) { + ZipArchiveEntry entry1 = new ZipArchiveEntry("test1.txt"); + entry1.setMethod(ZipMethod.ZSTD.getCode()); + entry1.setSize(TEST_DATA_1.length); + zos.putArchiveEntry(entry1); + // Without autoCompress, user must compress manually — writing raw data + zos.write(TEST_DATA_1); + ZipArchiveEntry entry2 = new ZipArchiveEntry("test2.txt"); + entry2.setMethod(ZipMethod.ZSTD.getCode()); + entry2.setSize(TEST_DATA_2.length); + zos.putArchiveEntry(entry2); + // Without autoCompress, user must compress manually — writing raw data + zos.write(TEST_DATA_2); + } + // data cannot be asserted here, as the user must compress manually + try (ZipFile zipFile = ZipFile.builder().setFile(file).get()) { + ZipArchiveEntry entry1 = zipFile.getEntry("test1.txt"); + assertEquals(ZipMethod.ZSTD.getCode(), entry1.getMethod()); + assertEquals(TEST_DATA_1.length, entry1.getSize()); + ZipArchiveEntry entry2 = zipFile.getEntry("test2.txt"); + assertEquals(ZipMethod.ZSTD.getCode(), entry2.getMethod()); + assertEquals(TEST_DATA_2.length, entry2.getSize()); + } + } + + private void assertZipFile(File file, ZipMethod zipMethod) throws Exception { + try (ZipFile zipFile = ZipFile.builder().setFile(file).get()) { + ZipArchiveEntry entry1 = zipFile.getEntry("test1.txt"); + assertEquals(zipMethod.getCode(), entry1.getMethod()); + assertEquals(TEST_DATA_1.length, entry1.getSize()); + assertArrayEquals(TEST_DATA_1, IOUtils.toByteArray(zipFile.getInputStream(entry1))); + ZipArchiveEntry entry2 = zipFile.getEntry("test2.txt"); + assertEquals(zipMethod.getCode(), entry2.getMethod()); + assertEquals(TEST_DATA_2.length, entry2.getSize()); + assertArrayEquals(TEST_DATA_2, IOUtils.toByteArray(zipFile.getInputStream(entry2))); + } + } +}