blob: 246805f87ba98665654e830728ad196f5fda5cc1 [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* 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 com.android.zipflinger;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class ZipArchive implements Archive {
private final FreeStore freestore;
private boolean closed;
private final Path file;
private final CentralDirectory cd;
private final ZipWriter writer;
private final ZipReader reader;
private final Zip64.Policy policy;
private ZipInfo zipInfo;
private boolean modified;
// The comment to append at the end of the EOCD.
@NonNull private byte[] comment;
public ZipArchive(@NonNull Path file) throws IOException {
this(file, Zip64.Policy.ALLOW);
}
/**
* The object used to manipulate a zip archive.
*
* @param file the file object
*/
public ZipArchive(@NonNull Path file, Zip64.Policy policy) throws IOException {
this.file = file;
this.policy = policy;
if (Files.exists(file)) {
ZipMap map = ZipMap.from(file, true, policy);
zipInfo = new ZipInfo(map.getPayloadLocation(), map.getCdLoc(), map.getEocdLoc());
cd = map.getCentralDirectory();
comment = map.getComment();
freestore = new FreeStore(map.getEntries());
} else {
zipInfo = new ZipInfo();
Map<String, Entry> entries = new LinkedHashMap<>();
cd = new CentralDirectory(ByteBuffer.allocate(0), entries);
comment = new byte[0];
freestore = new FreeStore(entries);
}
writer = new ZipWriter(file);
reader = new ZipReader(file);
closed = false;
modified = false;
}
/** @deprecated Use ZipArchive(Path) instead. */
@Deprecated
public ZipArchive(@NonNull File file) throws IOException {
this(file.toPath());
}
/** @deprecated Use {@link #ZipArchive(Path, Zip64.Policy)} instead. */
@Deprecated
public ZipArchive(@NonNull File file, Zip64.Policy policy) throws IOException {
this(file.toPath(), policy);
}
/**
* Returns the list of zip entries found in the archive. Note that these are the entries found
* in the central directory via bottom-up parsing, not all entries present in the payload as a
* top-down parser may return.
*
* @param file the zip archive to list.
* @return the list of entries in the archive, parsed bottom-up (via the Central Directory).
*/
@NonNull
public static Map<String, Entry> listEntries(@NonNull Path file) throws IOException {
return ZipMap.from(file, false).getEntries();
}
/** @deprecated Use {@link #listEntries(Path)} instead. */
@Deprecated
public static Map<String, Entry> listEntries(@NonNull File file) throws IOException {
return listEntries(file.toPath());
}
@NonNull
public List<String> listEntries() {
return cd.listEntries();
}
@Nullable
public ByteBuffer getContent(@NonNull String name) throws IOException {
ExtractionInfo extractInfo = cd.getExtractionInfo(name);
if (extractInfo == null) {
return null;
}
Location loc = extractInfo.getLocation();
ByteBuffer payloadByteBuffer = ByteBuffer.allocate(Math.toIntExact(loc.size()));
reader.read(payloadByteBuffer, loc.first);
if (extractInfo.isCompressed()) {
return Compressor.inflate(payloadByteBuffer.array(), extractInfo.getUnCompressedSize());
} else {
return payloadByteBuffer;
}
}
@Nullable
public InputStream getInputStream(@NonNull String name) throws IOException {
ExtractionInfo extractInfo = cd.getExtractionInfo(name);
if (extractInfo == null) {
return null;
}
InputStream in = new PayloadInputStream(reader.getChannel(), extractInfo.getLocation());
if (extractInfo.isCompressed()) {
return Compressor.wrapToInflate(in);
}
return in;
}
/** See Archive.add documentation */
@Override
public void add(@NonNull Source source) throws IOException {
if (closed) {
throw new IllegalStateException(
String.format("Cannot add source to closed archive %s", file));
}
writeSource(source);
}
/** See Archive.add documentation */
@Override
public void add(@NonNull ZipSource sources) throws IOException {
if (closed) {
throw new IllegalStateException(
String.format("Cannot add zip source to closed archive %s", file));
}
try {
sources.open();
for (Source source : sources.getSelectedEntries()) {
writeSource(source);
}
} finally {
sources.close();
}
}
/** See Archive.delete documentation */
@Override
public void delete(@NonNull String name) {
if (closed) {
throw new IllegalStateException(
String.format("Cannot delete '%s' from closed archive %s", name, file));
}
Location loc = cd.delete(name);
if (loc.isValid()) {
freestore.free(loc);
modified = true;
}
}
/**
* Carry all write operations to the storage system to reflect the delete/add operations
* requested via add/delete methods.
*/
// TODO: Zip64 -> Add boolean allowZip64
@Override
public void close() throws IOException {
closeWithInfo();
}
@NonNull
public ZipInfo closeWithInfo() throws IOException {
if (closed) {
throw new IllegalStateException("Attempt to close a closed archive");
}
closed = true;
try (ZipWriter w = writer;
ZipReader r = reader) {
writeArchive(w);
}
return zipInfo;
}
@NonNull
public Path getPath() {
return file;
}
public boolean isClosed() {
return closed;
}
private void writeArchive(@NonNull ZipWriter writer) throws IOException {
// There is no need to fill space and write footers if an already existing archive
// has not been modified.
if (zipInfo.eocd.isValid() && !modified) {
return;
}
// Fill all empty space with virtual entry (not the last one since it represent all of
// the unused file space.
List<Location> freeLocations = freestore.getFreeLocations();
for (int i = 0; i < freeLocations.size() - 1; i++) {
fillFreeLocation(freeLocations.get(i), writer);
}
// Write the Central Directory
Location lastFreeLocation = freestore.getLastFreeLocation();
long cdStart = lastFreeLocation.first;
writer.position(cdStart);
cd.write(writer);
Location cdLocation = new Location(cdStart, writer.position() - cdStart);
long numEntries = cd.getNumEntries();
// Write zip64 EOCD and Locator (only if needed)
writeZip64Footers(writer, cdLocation, numEntries);
// Write EOCD
Location eocdLocation =
EndOfCentralDirectory.write(writer, cdLocation, numEntries, comment);
writer.truncate(writer.position());
// Build and return location map
Location payLoadLocation = new Location(0, cdStart);
zipInfo = new ZipInfo(payLoadLocation, cdLocation, eocdLocation);
}
private void writeZip64Footers(
@NonNull ZipWriter writer, @NonNull Location cdLocation, long numEntries)
throws IOException {
if (!Zip64.needZip64Footer(numEntries, cdLocation)) {
return;
}
Zip64.checkFooterPolicy(policy, numEntries, cdLocation);
Zip64Eocd eocd = new Zip64Eocd(numEntries, cdLocation);
Location eocdLocation = eocd.write(writer);
Zip64Locator.write(writer, eocdLocation);
}
// Fill archive holes with virtual entries. Use extra field to fill as much as possible.
private static void fillFreeLocation(@NonNull Location location, @NonNull ZipWriter writer)
throws IOException {
long spaceToFill = location.size();
if (spaceToFill < LocalFileHeader.VIRTUAL_HEADER_SIZE) {
// There is not enough space to create a virtual entry here. The FreeStore
// never creates such gaps so it was already in the zip. Leave it as it is.
return;
}
while (spaceToFill > 0) {
long entrySize;
if (spaceToFill <= LocalFileHeader.VIRTUAL_ENTRY_MAX_SIZE) {
// Consume all the remaining space.
entrySize = spaceToFill;
} else {
// Consume as much as possible while leaving enough for the next LFH entry.
entrySize = Ints.USHRT_MAX;
}
int size = Math.toIntExact(entrySize);
ByteBuffer virtualEntry = ByteBuffer.allocate(size).order(ByteOrder.LITTLE_ENDIAN);
LocalFileHeader.fillVirtualEntry(virtualEntry);
writer.write(virtualEntry, location.first + location.size() - spaceToFill);
spaceToFill -= virtualEntry.capacity();
}
}
private void writeSource(@NonNull Source source) throws IOException {
// If this is a directory and it is already in the archive, just no-op.
if (Source.isNameDirectory(source.getName()) && cd.contains(source.getName())) {
return;
}
modified = true;
validateName(source);
source.prepare();
// Calculate the size we need (header + payload)
LocalFileHeader lfh = new LocalFileHeader(source);
long headerSize = lfh.getSize();
long bytesNeeded = headerSize + source.getCompressedSize();
// Allocate file space
Location loc;
if (source.isAligned()) {
loc = freestore.alloc(bytesNeeded, headerSize, source.getAlignment());
lfh.setPadding(Math.toIntExact(loc.size() - bytesNeeded));
} else {
loc = freestore.ualloc(bytesNeeded);
}
writer.position(loc.first);
lfh.write(writer);
// Write payload
long payloadStart = writer.position();
long payloadSize = source.writeTo(writer);
Location payloadLocation = new Location(payloadStart, payloadSize);
// Update Central Directory record
CentralDirectoryRecord cdRecord = new CentralDirectoryRecord(source, loc, payloadLocation);
cd.add(source.getName(), cdRecord);
Zip64.checkEntryPolicy(policy, source, loc, payloadLocation);
}
private void validateName(@NonNull Source source) {
byte[] nameBytes = source.getNameBytes();
String name = source.getName();
if (nameBytes.length > Ints.USHRT_MAX) {
throw new IllegalStateException(
String.format("Name '%s' is more than %d bytes", name, Ints.USHRT_MAX));
}
if (cd.contains(name)) {
String template = "Zip file '%s' already contains entry '%s', cannot overwrite";
String msg = String.format(template, file.toAbsolutePath().toString(), name);
throw new IllegalStateException(msg);
}
}
public void setComment(@NonNull byte[] comment) {
if (comment == null) {
throw new IllegalStateException("Null comment not permitted");
}
this.comment = comment;
}
}