CentralDirectory.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.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.channels.SeekableByteChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Central directory of a ZIP file.
 *
 * @since 6.0
 */
public class CentralDirectory {

    private final EndOfCentralDirectoryRecord endOfCentralDirectoryRecord = new EndOfCentralDirectoryRecord();
    private final Zip64EndOfCentralDirectoryLocator zip64EndOfCentralDirectoryLocator = new Zip64EndOfCentralDirectoryLocator();
    private final Zip64EndOfCentralDirectoryRecord zip64EndOfCentralDirectoryRecord = new Zip64EndOfCentralDirectoryRecord();

    /** The location of the central directory */
    public long centralDirectoryOffset = -1;

    /** The entries of the central directory */
    public Map<String, CentralDirectoryFileHeader> entries = new LinkedHashMap<>();

    public void read(SeekableByteChannel channel) throws IOException {
        endOfCentralDirectoryRecord.load(channel);
        if (endOfCentralDirectoryRecord.numberOfThisDisk > 0) {
            throw new IOException("Multi-volume archives are not supported");
        }

        long numberOfEntries;

        if (endOfCentralDirectoryRecord.centralDirectoryOffset == -1) {
            // look for the ZIP64 End of Central Directory Locator
            channel.position(channel.position() - Zip64EndOfCentralDirectoryLocator.SIZE);
            zip64EndOfCentralDirectoryLocator.read(channel);

            // read the ZIP64 End of Central Directory Record
            channel.position(zip64EndOfCentralDirectoryLocator.zip64EndOfCentralDirectoryRecordOffset);
            zip64EndOfCentralDirectoryRecord.read(channel);

            centralDirectoryOffset = zip64EndOfCentralDirectoryRecord.centralDirectoryOffset;
            numberOfEntries = (int) zip64EndOfCentralDirectoryRecord.numberOfEntries;
        } else {
            centralDirectoryOffset = endOfCentralDirectoryRecord.centralDirectoryOffset;
            numberOfEntries = endOfCentralDirectoryRecord.numberOfEntries;
        }

        // check if the offset is valid
        if (centralDirectoryOffset < 0 || centralDirectoryOffset > channel.size()) {
            throw new IOException("Invalid central directory offset: " + centralDirectoryOffset);
        }

        // read the entries
        channel.position(centralDirectoryOffset);
        for (int i = 0; i < numberOfEntries; i++) {
            CentralDirectoryFileHeader entry = new CentralDirectoryFileHeader();
            entry.read(channel);
            entries.put(new String(entry.fileName, StandardCharsets.ISO_8859_1), entry);
        }
    }

    /**
     * Write the central directory at the current position of the channel and update the offset.
     *
     * @param channel the channel to write to
     */
    public void write(SeekableByteChannel channel) throws IOException {
        long offset = channel.position();
        centralDirectoryOffset = offset;
        write(channel, offset);
    }

    /**
     * Write the central directory at the current position of the channel but don't update the offset.
     *
     * @param channel the channel to write to
     * @param offset the offset of the central directory written in the End of Central Directory Record
     */
    public void write(SeekableByteChannel channel, long offset) throws IOException {
        // sort and write the entries
        List<CentralDirectoryFileHeader> entries = new ArrayList<>(this.entries.values());
        entries.sort(Comparator.comparing(CentralDirectoryFileHeader::getLocalHeaderOffset));
        long position = channel.position();
        for (CentralDirectoryFileHeader entry : entries) {
            entry.write(channel);
        }

        long centralDirectorySize = channel.position() - position;

        // write the End of Central Directory Record
        if (endOfCentralDirectoryRecord.centralDirectoryOffset == -1 || offset > 0xFFFFFFFFL) {
            endOfCentralDirectoryRecord.centralDirectoryOffset = -1;
            endOfCentralDirectoryRecord.centralDirectorySize = -1;

            zip64EndOfCentralDirectoryRecord.numberOfEntriesOnThisDisk = entries.size();
            zip64EndOfCentralDirectoryRecord.numberOfEntries = entries.size();
            zip64EndOfCentralDirectoryRecord.centralDirectorySize = centralDirectorySize;
            zip64EndOfCentralDirectoryRecord.centralDirectoryOffset = offset;
            zip64EndOfCentralDirectoryRecord.write(channel);

            zip64EndOfCentralDirectoryLocator.zip64EndOfCentralDirectoryRecordOffset = offset + centralDirectorySize;
            zip64EndOfCentralDirectoryLocator.write(channel);

        } else {
            endOfCentralDirectoryRecord.numberOfEntriesOnThisDisk = entries.size();
            endOfCentralDirectoryRecord.numberOfEntries = entries.size();
            endOfCentralDirectoryRecord.centralDirectorySize = (int) centralDirectorySize;
            endOfCentralDirectoryRecord.centralDirectoryOffset = (int) offset;
        }

        endOfCentralDirectoryRecord.numberOfThisDisk = 0;
        endOfCentralDirectoryRecord.numberOfTheDiskWithTheStartOfTheCentralDirectory = 0;
        endOfCentralDirectoryRecord.write(channel);
    }

    /**
     * Removes the entry specified if it exists. Only the last entry can be removed.
     *
     * @param name the name of the entry to remove
     */
    public void removeEntry(String name) {
        if (entries.containsKey(name)) {
            CentralDirectoryFileHeader centralDirectoryFileHeader = entries.get(name);
            long size = getEntrySize(name);

            // remove the entry
            entries.remove(name);

            // shift the central directory offset
            centralDirectoryOffset = centralDirectoryOffset - size;

            // shift the local header offset of the following entries
            for (CentralDirectoryFileHeader entry : entries.values()) {
                if (entry.getLocalHeaderOffset() > centralDirectoryFileHeader.getLocalHeaderOffset()) {
                    entry.setLocalHeaderOffset(entry.getLocalHeaderOffset() - size);
                }
            }
        }
    }

    /**
     * Returns the size of the specified entry (local header + compressed data).
     *
     * @param name the name of the entry
     * @since 7.0
     */
    public long getEntrySize(String name) {
        CentralDirectoryFileHeader centralDirectoryFileHeader = entries.get(name);

        // the size is the smallest strictly positive distance between the offset and the entry and the others
        long size = centralDirectoryOffset - centralDirectoryFileHeader.getLocalHeaderOffset();
        for (CentralDirectoryFileHeader entry : entries.values()) {
            long distance = entry.getLocalHeaderOffset() - centralDirectoryFileHeader.getLocalHeaderOffset();
            if (distance > 0 && distance < size) {
                size = distance;
            }
        }

        return size;
    }

    /**
     * Returns the central directory as a byte array.
     */
    public byte[] toBytes() throws IOException {
        File tmp = File.createTempFile("jsign-zip-central-directory", ".bin");
        tmp.deleteOnExit();
        try (RandomAccessFile raf = new RandomAccessFile(tmp, "rw")) {
            write(raf.getChannel(), centralDirectoryOffset);
            return Files.readAllBytes(tmp.toPath());
        } finally {
            tmp.delete();
        }
    }
}