/*
* Copyright 2015-present Facebook, Inc.
*
* 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.facebook.buck.zip;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.zip.ZipEntry;
/** Tool to eliminate non-deterministic or problematic bits of zip files. */
class ZipScrubber {
private ZipScrubber() {}
private static final int EXTENDED_TIMESTAMP_ID = 0x5455;
private static final int DATA_DESCRIPTOR_BIT_FLAG = 0x0008;
private static void check(boolean expression, String msg) throws IOException {
if (!expression) {
throw new IOException(msg);
}
}
static void scrubZip(Path zipPath) throws IOException {
try (FileChannel channel =
FileChannel.open(zipPath, StandardOpenOption.READ, StandardOpenOption.WRITE)) {
MappedByteBuffer map = channel.map(FileChannel.MapMode.READ_WRITE, 0, channel.size());
map.order(ByteOrder.LITTLE_ENDIAN);
// Search backwards from the end of the ZIP file, searching for the EOCD signature, which
// designates the start of the EOCD.
int eocdOffset = (int) channel.size() - ZipEntry.ENDHDR;
while (map.getInt(eocdOffset) != ZipEntry.ENDSIG) {
eocdOffset--;
}
int cdEntries = map.getShort(eocdOffset + ZipEntry.ENDTOT);
int cdOffset = map.getInt(eocdOffset + ZipEntry.ENDOFF);
for (int idx = 0; idx < cdEntries; idx++) {
// Wrap the central directory header and zero out it's timestamp.
ByteBuffer entry = slice(map, cdOffset);
check(entry.getInt(0) == ZipEntry.CENSIG, "expected central directory header signature");
entry.putInt(ZipEntry.CENTIM, ZipConstants.DOS_FAKE_TIME);
ByteBuffer localEntry = slice(map, entry.getInt(ZipEntry.CENOFF));
scrubLocalEntry(localEntry);
scrubExtraFields(
slice(entry, ZipEntry.CENHDR + entry.getShort(ZipEntry.CENNAM)),
entry.getShort(ZipEntry.CENEXT));
if (idx == cdEntries - 1) {
// Only run this on the last entry.
populateLocalEntryIfNecessary(entry, localEntry);
}
cdOffset +=
ZipEntry.CENHDR
+ entry.getShort(ZipEntry.CENNAM)
+ entry.getShort(ZipEntry.CENEXT)
+ entry.getShort(ZipEntry.CENCOM);
}
}
}
private static ByteBuffer slice(ByteBuffer map, int offset) {
ByteBuffer result = map.duplicate();
result.position(offset);
result = result.slice();
result.order(ByteOrder.LITTLE_ENDIAN);
return result;
}
private static void scrubLocalEntry(ByteBuffer entry) throws IOException {
check(entry.getInt(0) == ZipEntry.LOCSIG, "expected local header signature");
entry.putInt(ZipEntry.LOCTIM, ZipConstants.DOS_FAKE_TIME);
scrubExtraFields(
slice(entry, ZipEntry.LOCHDR + entry.getShort(ZipEntry.LOCNAM)),
entry.getShort(ZipEntry.LOCEXT));
}
private static void scrubExtraFields(ByteBuffer data, short length) {
// See http://mdfs.net/Docs/Comp/Archiving/Zip/ExtraField for structure of extra fields.
int end = data.position() + length;
while (data.position() < end) {
int id = data.getShort();
int size = data.getShort();
if (id == EXTENDED_TIMESTAMP_ID) {
// 1 byte flag
// 0-3 4-byte unix timestamps
data.get(); // ignore flags
size -= 1;
while (size > 0) {
data.putInt((int) (ZipConstants.getFakeTime() / 1000));
size -= 4;
}
} else {
if (data.position() + size >= end) {
break;
}
data.position(data.position() + size);
}
}
}
/**
* Android's libziparchive produces zip files that only store the file size in the central
* directory and data descriptor, not in the local file header. ZipInputStream doesn't tolerate
* this for STORED files, so this step will (1) identify whether the file is STORED with a data
* descriptor, (2) validate the DD against the central directory entry, (3) copy those values to
* the local file header, (4) clear the DD bit.
*
* <p>We leave the data descriptor as garbage data between entries, WHICH IS A PROBLEM for
* ZipInputStream, which can't tolerate that garbage (it views it as end-of-file). Therefore, we
* only call this on the last file in the zip (which is the only time it is needed for aapt2
* output).
*/
@SuppressWarnings("PMD.PrematureDeclaration")
private static void populateLocalEntryIfNecessary(ByteBuffer centralEntry, ByteBuffer localEntry)
throws IOException {
// Check to see if we even need to do anything.
if (localEntry.getShort(ZipEntry.LOCHOW) != ZipEntry.STORED
|| (localEntry.getShort(ZipEntry.LOCFLG) & DATA_DESCRIPTOR_BIT_FLAG) == 0) {
return;
}
// Load the data from the central directory.
int crc = centralEntry.getInt(ZipEntry.CENCRC);
int csize = centralEntry.getInt(ZipEntry.CENSIZ);
int usize = centralEntry.getInt(ZipEntry.CENLEN);
if (csize != usize) {
throw new IOException("Compressed and uncompressed size mismatch for STORED entry.");
}
// Load the data from the data descriptor as a double-check.
int dataDescriptorOffset =
ZipEntry.LOCHDR
+ localEntry.getShort(ZipEntry.LOCNAM)
+ localEntry.getShort(ZipEntry.LOCEXT)
+ csize;
ByteBuffer extBuffer = slice(localEntry, dataDescriptorOffset);
int extsig = extBuffer.getInt(0);
if (extsig != ZipEntry.EXTSIG) {
throw new IOException("No EXT sig. Too dangerous to proceed.");
}
int extcrc = extBuffer.getInt(ZipEntry.EXTCRC);
int extcsize = extBuffer.getInt(ZipEntry.EXTSIZ);
int extusize = extBuffer.getInt(ZipEntry.EXTLEN);
if (extcrc != crc) {
throw new IOException("CRC mismatch between central entry and data descriptor");
}
if (extcsize != csize) {
throw new IOException("Size mismatch between central entry and data descriptor");
}
if (extusize != usize) {
throw new IOException("Length mismatch between central entry and data descriptor");
}
// Write the data into the local entry.
localEntry.putInt(ZipEntry.LOCCRC, crc);
localEntry.putInt(ZipEntry.LOCSIZ, csize);
localEntry.putInt(ZipEntry.LOCLEN, usize);
localEntry.putShort(
ZipEntry.LOCFLG,
(short) (localEntry.getShort(ZipEntry.LOCFLG) & ~DATA_DESCRIPTOR_BIT_FLAG));
}
/** Read the name of a zip file from a local entry. Useful for debugging. */
@SuppressWarnings("unused")
private static String localEntryName(ByteBuffer entry) {
byte[] nameBytes = new byte[entry.getShort(ZipEntry.LOCNAM)];
((ByteBuffer) entry.slice().position(ZipEntry.LOCHDR)).get(nameBytes);
return new String(nameBytes);
}
public static void main(String[] args) throws IOException {
if (args.length != 1) {
System.err.println("usage: ZipScrubberCli file-to-scrub-in-place.zip");
System.exit(2);
}
scrubZip(Paths.get(args[0]));
}
}