001/*
002 * Copyright 2023 Emmanuel Bourg
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package net.jsign.zip;
018
019import java.io.ByteArrayOutputStream;
020import java.io.Closeable;
021import java.io.File;
022import java.io.IOException;
023import java.io.InputStream;
024import java.nio.ByteBuffer;
025import java.nio.channels.Channels;
026import java.nio.channels.SeekableByteChannel;
027import java.nio.file.Files;
028import java.nio.file.StandardOpenOption;
029import java.util.zip.CRC32;
030import java.util.zip.Deflater;
031import java.util.zip.DeflaterOutputStream;
032import java.util.zip.Inflater;
033import java.util.zip.InflaterInputStream;
034
035import org.apache.commons.io.input.BoundedInputStream;
036
037import net.jsign.ChannelUtils;
038
039import static java.nio.charset.StandardCharsets.*;
040
041/**
042 * Simplified implementation of the ZIP file format, just good enough to add an entry to an existing file.
043 *
044 * @since 6.0
045 */
046public class ZipFile implements Closeable {
047
048    /** The channel used for in-memory signing */
049    protected final SeekableByteChannel channel;
050
051    protected CentralDirectory centralDirectory;
052
053    /**
054     * Create a ZipFile from the specified file.
055     *
056     * @param file the file to open
057     * @throws IOException if an I/O error occurs
058     */
059    public ZipFile(File file) throws IOException {
060        this(Files.newByteChannel(file.toPath(), StandardOpenOption.READ, StandardOpenOption.WRITE));
061    }
062
063    /**
064     * Create a ZipFile from the specified channel.
065     *
066     * @param channel the channel to read the file from
067     * @throws IOException if an I/O error occurs
068     */
069    public ZipFile(SeekableByteChannel channel) throws IOException {
070        this.channel = channel;
071        centralDirectory = new CentralDirectory();
072        centralDirectory.read(channel);
073    }
074
075    public InputStream getInputStream(String name) throws IOException {
076        return getInputStream(name, -1);
077    }
078
079    public InputStream getInputStream(String name, int limit) throws IOException {
080        CentralDirectoryFileHeader header = centralDirectory.entries.get(name);
081        if (header == null) {
082            throw new IOException("Entry not found: " + name);
083        }
084        if (limit != -1 && header.getUncompressedSize() > limit) {
085            throw new IOException("The entry " + name + " is too large to be read (" + header.getUncompressedSize() + " bytes)");
086        }
087        channel.position(header.getLocalHeaderOffset());
088
089        LocalFileHeader localFileHeader = new LocalFileHeader();
090        localFileHeader.read(channel);
091        InputStream in = Channels.newInputStream(channel);
092        in = new BoundedInputStream(in, header.getCompressedSize());
093        switch (header.compressionMethod) {
094            case 0 /* STORED */:
095                return in;
096            case 8 /* DEFLATED */:
097                Inflater inflater = new Inflater(true);
098                return new InflaterInputStream(in, inflater);
099            default:
100                throw new IOException("Unsupported compression method " + header.compressionMethod + " for entry " + name);
101        }
102    }
103
104    public void addEntry(String name, byte[] data, boolean compressed) throws IOException {
105        // compute CRC32 of the uncompressed data
106        CRC32 crc32 = new CRC32();
107        crc32.update(data);
108
109        int uncompressedSize = data.length;
110        int compressedSize;
111
112        if (compressed) {
113            // deflate the data
114            Deflater deflater = new Deflater(9, true);
115            ByteArrayOutputStream bos = new ByteArrayOutputStream();
116            DeflaterOutputStream dos = new DeflaterOutputStream(bos, deflater);
117            dos.write(data);
118            dos.flush();
119            dos.close();
120
121            data = bos.toByteArray();
122            compressedSize = data.length;
123        } else {
124            compressedSize = uncompressedSize;
125        }
126
127        LocalFileHeader localFileHeader = new LocalFileHeader();
128        localFileHeader.versionNeededToExtract = 20;
129        localFileHeader.generalPurposeBitFlag = 0;
130        localFileHeader.compressionMethod = compressed ? 8 : 0;
131        localFileHeader.lastModFileTime = 0b00000_00000_00000; // 00:00:00
132        localFileHeader.lastModFileDate = 0b0000000_0001_00001; // 1980-01-01
133        localFileHeader.crc32 = (int) crc32.getValue();
134        localFileHeader.compressedSize = compressedSize;
135        localFileHeader.uncompressedSize = uncompressedSize;
136        localFileHeader.fileName = name.getBytes(UTF_8);
137
138        channel.position(centralDirectory.centralDirectoryOffset);
139        long offset = channel.position();
140        localFileHeader.write(channel);
141        channel.write(ByteBuffer.wrap(data));
142
143        boolean needsZip64 = offset > 0xFFFFFFFFL;
144
145        CentralDirectoryFileHeader centralDirectoryFileHeader = new CentralDirectoryFileHeader();
146        centralDirectoryFileHeader.versionMadeBy = 45;
147        centralDirectoryFileHeader.versionNeededToExtract = 20;
148        centralDirectoryFileHeader.generalPurposeBitFlag = localFileHeader.generalPurposeBitFlag;
149        centralDirectoryFileHeader.compressionMethod = localFileHeader.compressionMethod;
150        centralDirectoryFileHeader.lastModFileTime = localFileHeader.lastModFileTime;
151        centralDirectoryFileHeader.lastModFileDate = localFileHeader.lastModFileDate;
152        centralDirectoryFileHeader.crc32 = localFileHeader.crc32;
153        centralDirectoryFileHeader.compressedSize = localFileHeader.compressedSize;
154        centralDirectoryFileHeader.uncompressedSize = uncompressedSize;
155        centralDirectoryFileHeader.diskNumberStart = 0;
156        centralDirectoryFileHeader.internalFileAttributes = 0;
157        centralDirectoryFileHeader.externalFileAttributes = 0;
158        centralDirectoryFileHeader.localHeaderOffset = needsZip64 ? 0xFFFFFFFFL : offset;
159        centralDirectoryFileHeader.fileName = localFileHeader.fileName;
160
161        if (needsZip64) {
162            Zip64ExtendedInfoExtraField zip64ExtraField = new Zip64ExtendedInfoExtraField(-1, -1, offset, -1);
163            centralDirectoryFileHeader.extraFields.put(zip64ExtraField.id, zip64ExtraField);
164        }
165
166        centralDirectory.entries.put(name, centralDirectoryFileHeader);
167
168        centralDirectory.write(channel);
169    }
170
171    public void renameEntry(String oldName, String newName) throws IOException {
172        if (oldName.length() != newName.length()) {
173            throw new IllegalArgumentException("The new name must have the same length");
174        }
175        CentralDirectoryFileHeader centralDirectoryFileHeader = centralDirectory.entries.get(oldName);
176        centralDirectoryFileHeader.fileName = newName.getBytes(UTF_8);
177        centralDirectory.entries.remove(oldName);
178        centralDirectory.entries.put(newName, centralDirectoryFileHeader);
179
180        long offset = centralDirectoryFileHeader.getLocalHeaderOffset();
181        channel.position(offset);
182        LocalFileHeader localFileHeader = new LocalFileHeader();
183        localFileHeader.read(channel);
184        localFileHeader.fileName = newName.getBytes(UTF_8);
185        channel.position(offset);
186        localFileHeader.write(channel);
187
188        channel.position(centralDirectory.centralDirectoryOffset);
189        centralDirectory.write(channel);
190    }
191
192    public void removeEntry(String name) throws IOException {
193        CentralDirectoryFileHeader centralDirectoryFileHeader = centralDirectory.entries.get(name);
194        ChannelUtils.delete(channel, centralDirectoryFileHeader.getLocalHeaderOffset(), centralDirectory.getEntrySize(name));
195
196        centralDirectory.removeEntry(name);
197
198        channel.position(centralDirectory.centralDirectoryOffset);
199        centralDirectory.write(channel);
200        channel.truncate(channel.position());
201    }
202
203    @Override
204    public void close() throws IOException {
205        if (channel != null) {
206            channel.close();
207        }
208    }
209}