ZipFile.java

/*
 * Copyright 2023 Emmanuel Bourg
 *
 * Licensed 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 net.jsign.zip;

import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.zip.CRC32;
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;

import org.apache.commons.io.input.BoundedInputStream;

import net.jsign.ChannelUtils;

import static java.nio.charset.StandardCharsets.*;

/**
 * Simplified implementation of the ZIP file format, just good enough to add an entry to an existing file.
 *
 * @since 6.0
 */
public class ZipFile implements Closeable {

    /** The channel used for in-memory signing */
    protected final SeekableByteChannel channel;

    protected CentralDirectory centralDirectory;

    /**
     * Create a ZipFile from the specified file.
     *
     * @param file the file to open
     * @throws IOException if an I/O error occurs
     */
    public ZipFile(File file) throws IOException {
        this(Files.newByteChannel(file.toPath(), StandardOpenOption.READ, StandardOpenOption.WRITE));
    }

    /**
     * Create a ZipFile from the specified channel.
     *
     * @param channel the channel to read the file from
     * @throws IOException if an I/O error occurs
     */
    public ZipFile(SeekableByteChannel channel) throws IOException {
        this.channel = channel;
        centralDirectory = new CentralDirectory();
        centralDirectory.read(channel);
    }

    public InputStream getInputStream(String name) throws IOException {
        return getInputStream(name, -1);
    }

    public InputStream getInputStream(String name, int limit) throws IOException {
        CentralDirectoryFileHeader header = centralDirectory.entries.get(name);
        if (header == null) {
            throw new IOException("Entry not found: " + name);
        }
        if (limit != -1 && header.getUncompressedSize() > limit) {
            throw new IOException("The entry " + name + " is too large to be read (" + header.getUncompressedSize() + " bytes)");
        }
        channel.position(header.getLocalHeaderOffset());

        LocalFileHeader localFileHeader = new LocalFileHeader();
        localFileHeader.read(channel);
        InputStream in = Channels.newInputStream(channel);
        in = new BoundedInputStream(in, header.getCompressedSize());
        switch (header.compressionMethod) {
            case 0 /* STORED */:
                return in;
            case 8 /* DEFLATED */:
                Inflater inflater = new Inflater(true);
                return new InflaterInputStream(in, inflater);
            default:
                throw new IOException("Unsupported compression method " + header.compressionMethod + " for entry " + name);
        }
    }

    public void addEntry(String name, byte[] data, boolean compressed) throws IOException {
        // compute CRC32 of the uncompressed data
        CRC32 crc32 = new CRC32();
        crc32.update(data);

        int uncompressedSize = data.length;
        int compressedSize;

        if (compressed) {
            // deflate the data
            Deflater deflater = new Deflater(9, true);
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            DeflaterOutputStream dos = new DeflaterOutputStream(bos, deflater);
            dos.write(data);
            dos.flush();
            dos.close();

            data = bos.toByteArray();
            compressedSize = data.length;
        } else {
            compressedSize = uncompressedSize;
        }

        LocalFileHeader localFileHeader = new LocalFileHeader();
        localFileHeader.versionNeededToExtract = 20;
        localFileHeader.generalPurposeBitFlag = 0;
        localFileHeader.compressionMethod = compressed ? 8 : 0;
        localFileHeader.lastModFileTime = 0b00000_00000_00000; // 00:00:00
        localFileHeader.lastModFileDate = 0b0000000_0001_00001; // 1980-01-01
        localFileHeader.crc32 = (int) crc32.getValue();
        localFileHeader.compressedSize = compressedSize;
        localFileHeader.uncompressedSize = uncompressedSize;
        localFileHeader.fileName = name.getBytes(UTF_8);

        channel.position(centralDirectory.centralDirectoryOffset);
        long offset = channel.position();
        localFileHeader.write(channel);
        channel.write(ByteBuffer.wrap(data));

        boolean needsZip64 = offset > 0xFFFFFFFFL;

        CentralDirectoryFileHeader centralDirectoryFileHeader = new CentralDirectoryFileHeader();
        centralDirectoryFileHeader.versionMadeBy = 45;
        centralDirectoryFileHeader.versionNeededToExtract = 20;
        centralDirectoryFileHeader.generalPurposeBitFlag = localFileHeader.generalPurposeBitFlag;
        centralDirectoryFileHeader.compressionMethod = localFileHeader.compressionMethod;
        centralDirectoryFileHeader.lastModFileTime = localFileHeader.lastModFileTime;
        centralDirectoryFileHeader.lastModFileDate = localFileHeader.lastModFileDate;
        centralDirectoryFileHeader.crc32 = localFileHeader.crc32;
        centralDirectoryFileHeader.compressedSize = localFileHeader.compressedSize;
        centralDirectoryFileHeader.uncompressedSize = uncompressedSize;
        centralDirectoryFileHeader.diskNumberStart = 0;
        centralDirectoryFileHeader.internalFileAttributes = 0;
        centralDirectoryFileHeader.externalFileAttributes = 0;
        centralDirectoryFileHeader.localHeaderOffset = needsZip64 ? 0xFFFFFFFFL : offset;
        centralDirectoryFileHeader.fileName = localFileHeader.fileName;

        if (needsZip64) {
            Zip64ExtendedInfoExtraField zip64ExtraField = new Zip64ExtendedInfoExtraField(-1, -1, offset, -1);
            centralDirectoryFileHeader.extraFields.put(zip64ExtraField.id, zip64ExtraField);
        }

        centralDirectory.entries.put(name, centralDirectoryFileHeader);

        centralDirectory.write(channel);
    }

    public void renameEntry(String oldName, String newName) throws IOException {
        if (oldName.length() != newName.length()) {
            throw new IllegalArgumentException("The new name must have the same length");
        }
        CentralDirectoryFileHeader centralDirectoryFileHeader = centralDirectory.entries.get(oldName);
        centralDirectoryFileHeader.fileName = newName.getBytes(UTF_8);
        centralDirectory.entries.remove(oldName);
        centralDirectory.entries.put(newName, centralDirectoryFileHeader);

        long offset = centralDirectoryFileHeader.getLocalHeaderOffset();
        channel.position(offset);
        LocalFileHeader localFileHeader = new LocalFileHeader();
        localFileHeader.read(channel);
        localFileHeader.fileName = newName.getBytes(UTF_8);
        channel.position(offset);
        localFileHeader.write(channel);

        channel.position(centralDirectory.centralDirectoryOffset);
        centralDirectory.write(channel);
    }

    public void removeEntry(String name) throws IOException {
        CentralDirectoryFileHeader centralDirectoryFileHeader = centralDirectory.entries.get(name);
        ChannelUtils.delete(channel, centralDirectoryFileHeader.getLocalHeaderOffset(), centralDirectory.getEntrySize(name));

        centralDirectory.removeEntry(name);

        channel.position(centralDirectory.centralDirectoryOffset);
        centralDirectory.write(channel);
        channel.truncate(channel.position());
    }

    @Override
    public void close() throws IOException {
        if (channel != null) {
            channel.close();
        }
    }
}