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.File;
020import java.io.IOException;
021import java.io.RandomAccessFile;
022import java.nio.channels.SeekableByteChannel;
023import java.nio.charset.StandardCharsets;
024import java.nio.file.Files;
025import java.util.ArrayList;
026import java.util.Comparator;
027import java.util.LinkedHashMap;
028import java.util.List;
029import java.util.Map;
030
031/**
032 * Central directory of a ZIP file.
033 *
034 * @since 6.0
035 */
036public class CentralDirectory {
037
038    private final EndOfCentralDirectoryRecord endOfCentralDirectoryRecord = new EndOfCentralDirectoryRecord();
039    private final Zip64EndOfCentralDirectoryLocator zip64EndOfCentralDirectoryLocator = new Zip64EndOfCentralDirectoryLocator();
040    private final Zip64EndOfCentralDirectoryRecord zip64EndOfCentralDirectoryRecord = new Zip64EndOfCentralDirectoryRecord();
041
042    /** The location of the central directory */
043    public long centralDirectoryOffset = -1;
044
045    /** The entries of the central directory */
046    public Map<String, CentralDirectoryFileHeader> entries = new LinkedHashMap<>();
047
048    public void read(SeekableByteChannel channel) throws IOException {
049        endOfCentralDirectoryRecord.load(channel);
050        if (endOfCentralDirectoryRecord.numberOfThisDisk > 0) {
051            throw new IOException("Multi-volume archives are not supported");
052        }
053
054        long numberOfEntries;
055
056        if (endOfCentralDirectoryRecord.centralDirectoryOffset == -1) {
057            // look for the ZIP64 End of Central Directory Locator
058            channel.position(channel.position() - Zip64EndOfCentralDirectoryLocator.SIZE);
059            zip64EndOfCentralDirectoryLocator.read(channel);
060
061            // read the ZIP64 End of Central Directory Record
062            channel.position(zip64EndOfCentralDirectoryLocator.zip64EndOfCentralDirectoryRecordOffset);
063            zip64EndOfCentralDirectoryRecord.read(channel);
064
065            centralDirectoryOffset = zip64EndOfCentralDirectoryRecord.centralDirectoryOffset;
066            numberOfEntries = (int) zip64EndOfCentralDirectoryRecord.numberOfEntries;
067        } else {
068            centralDirectoryOffset = endOfCentralDirectoryRecord.centralDirectoryOffset;
069            numberOfEntries = endOfCentralDirectoryRecord.numberOfEntries;
070        }
071
072        // check if the offset is valid
073        if (centralDirectoryOffset < 0 || centralDirectoryOffset > channel.size()) {
074            throw new IOException("Invalid central directory offset: " + centralDirectoryOffset);
075        }
076
077        // read the entries
078        channel.position(centralDirectoryOffset);
079        for (int i = 0; i < numberOfEntries; i++) {
080            CentralDirectoryFileHeader entry = new CentralDirectoryFileHeader();
081            entry.read(channel);
082            entries.put(new String(entry.fileName, StandardCharsets.ISO_8859_1), entry);
083        }
084    }
085
086    /**
087     * Write the central directory at the current position of the channel and update the offset.
088     *
089     * @param channel the channel to write to
090     */
091    public void write(SeekableByteChannel channel) throws IOException {
092        long offset = channel.position();
093        centralDirectoryOffset = offset;
094        write(channel, offset);
095    }
096
097    /**
098     * Write the central directory at the current position of the channel but don't update the offset.
099     *
100     * @param channel the channel to write to
101     * @param offset the offset of the central directory written in the End of Central Directory Record
102     */
103    public void write(SeekableByteChannel channel, long offset) throws IOException {
104        // sort and write the entries
105        List<CentralDirectoryFileHeader> entries = new ArrayList<>(this.entries.values());
106        entries.sort(Comparator.comparing(CentralDirectoryFileHeader::getLocalHeaderOffset));
107        long position = channel.position();
108        for (CentralDirectoryFileHeader entry : entries) {
109            entry.write(channel);
110        }
111
112        long centralDirectorySize = channel.position() - position;
113
114        // write the End of Central Directory Record
115        if (endOfCentralDirectoryRecord.centralDirectoryOffset == -1 || offset > 0xFFFFFFFFL) {
116            endOfCentralDirectoryRecord.centralDirectoryOffset = -1;
117            endOfCentralDirectoryRecord.centralDirectorySize = -1;
118
119            zip64EndOfCentralDirectoryRecord.numberOfEntriesOnThisDisk = entries.size();
120            zip64EndOfCentralDirectoryRecord.numberOfEntries = entries.size();
121            zip64EndOfCentralDirectoryRecord.centralDirectorySize = centralDirectorySize;
122            zip64EndOfCentralDirectoryRecord.centralDirectoryOffset = offset;
123            zip64EndOfCentralDirectoryRecord.write(channel);
124
125            zip64EndOfCentralDirectoryLocator.zip64EndOfCentralDirectoryRecordOffset = offset + centralDirectorySize;
126            zip64EndOfCentralDirectoryLocator.write(channel);
127
128        } else {
129            endOfCentralDirectoryRecord.numberOfEntriesOnThisDisk = entries.size();
130            endOfCentralDirectoryRecord.numberOfEntries = entries.size();
131            endOfCentralDirectoryRecord.centralDirectorySize = (int) centralDirectorySize;
132            endOfCentralDirectoryRecord.centralDirectoryOffset = (int) offset;
133        }
134
135        endOfCentralDirectoryRecord.numberOfThisDisk = 0;
136        endOfCentralDirectoryRecord.numberOfTheDiskWithTheStartOfTheCentralDirectory = 0;
137        endOfCentralDirectoryRecord.write(channel);
138    }
139
140    /**
141     * Removes the entry specified if it exists. Only the last entry can be removed.
142     *
143     * @param name the name of the entry to remove
144     */
145    public void removeEntry(String name) {
146        if (entries.containsKey(name)) {
147            CentralDirectoryFileHeader centralDirectoryFileHeader = entries.get(name);
148            long size = getEntrySize(name);
149
150            // remove the entry
151            entries.remove(name);
152
153            // shift the central directory offset
154            centralDirectoryOffset = centralDirectoryOffset - size;
155
156            // shift the local header offset of the following entries
157            for (CentralDirectoryFileHeader entry : entries.values()) {
158                if (entry.getLocalHeaderOffset() > centralDirectoryFileHeader.getLocalHeaderOffset()) {
159                    entry.setLocalHeaderOffset(entry.getLocalHeaderOffset() - size);
160                }
161            }
162        }
163    }
164
165    /**
166     * Returns the size of the specified entry (local header + compressed data).
167     *
168     * @param name the name of the entry
169     * @since 7.0
170     */
171    public long getEntrySize(String name) {
172        CentralDirectoryFileHeader centralDirectoryFileHeader = entries.get(name);
173
174        // the size is the smallest strictly positive distance between the offset and the entry and the others
175        long size = centralDirectoryOffset - centralDirectoryFileHeader.getLocalHeaderOffset();
176        for (CentralDirectoryFileHeader entry : entries.values()) {
177            long distance = entry.getLocalHeaderOffset() - centralDirectoryFileHeader.getLocalHeaderOffset();
178            if (distance > 0 && distance < size) {
179                size = distance;
180            }
181        }
182
183        return size;
184    }
185
186    /**
187     * Returns the central directory as a byte array.
188     */
189    public byte[] toBytes() throws IOException {
190        File tmp = File.createTempFile("jsign-zip-central-directory", ".bin");
191        tmp.deleteOnExit();
192        try (RandomAccessFile raf = new RandomAccessFile(tmp, "rw")) {
193            write(raf.getChannel(), centralDirectoryOffset);
194            return Files.readAllBytes(tmp.toPath());
195        } finally {
196            tmp.delete();
197        }
198    }
199}