/*
* Copyright 2012-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 static com.facebook.buck.zip.ZipOutputStreams.HandleDuplicates.THROW_EXCEPTION;
import com.facebook.buck.event.ConsoleEvent;
import com.facebook.buck.io.MorePaths;
import com.facebook.buck.io.ProjectFilesystem;
import com.facebook.buck.log.Logger;
import com.facebook.buck.model.Pair;
import com.facebook.buck.step.ExecutionContext;
import com.facebook.buck.step.Step;
import com.facebook.buck.step.StepExecutionResult;
import com.google.common.collect.ImmutableSet;
import com.google.common.hash.Hashing;
import com.google.common.io.ByteSource;
import com.google.common.io.ByteStreams;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.FileVisitResult;
import java.nio.file.FileVisitor;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.zip.ZipEntry;
/** A {@link com.facebook.buck.step.Step} that creates a ZIP archive.. */
@SuppressWarnings("PMD.AvoidUsingOctalValues")
public class ZipStep implements Step {
private static final Logger LOG = Logger.get(ZipStep.class);
private final ProjectFilesystem filesystem;
private final Path pathToZipFile;
private final ImmutableSet<Path> paths;
private final boolean junkPaths;
private final ZipCompressionLevel compressionLevel;
private final Path baseDir;
/**
* Create a {@link ZipStep} to create or update a zip archive.
*
* <p>Note that paths added to the archive are always relative to the working directory.<br>
* For example, if you're in {@code /dir} and you add {@code file.txt}, you get an archive
* containing just the file. If you were in {@code /} and added {@code dir/file.txt}, you would
* get an archive containing the file within a directory.
*
* @param pathToZipFile path to archive to create, relative to project root.
* @param paths a set of files to work on. The entire working directory is assumed if this set is
* empty.
* @param junkPaths if {@code true}, the relative paths of added archive entries are discarded,
* i.e. they are all placed in the root of the archive.
* @param compressionLevel between 0 (store) and 9.
* @param baseDir working directory for {@code zip} command.
*/
public ZipStep(
ProjectFilesystem filesystem,
Path pathToZipFile,
Set<Path> paths,
boolean junkPaths,
ZipCompressionLevel compressionLevel,
Path baseDir) {
this.filesystem = filesystem;
this.pathToZipFile = pathToZipFile;
this.paths = ImmutableSet.copyOf(paths);
this.junkPaths = junkPaths;
this.compressionLevel = compressionLevel;
this.baseDir = baseDir;
}
@Override
public StepExecutionResult execute(ExecutionContext context) {
if (filesystem.exists(pathToZipFile)) {
context.postEvent(
ConsoleEvent.severe("Attempting to overwrite an existing zip: %s", pathToZipFile));
return StepExecutionResult.ERROR;
}
// Since filesystem traversals can be non-deterministic, sort the entries we find into
// a tree map before writing them out.
final Map<String, Pair<CustomZipEntry, Optional<Path>>> entries = new TreeMap<>();
FileVisitor<Path> pathFileVisitor =
new SimpleFileVisitor<Path>() {
private boolean isSkipFile(Path file) {
return !paths.isEmpty() && !paths.contains(file);
}
private String getEntryName(Path path) {
Path relativePath = junkPaths ? path.getFileName() : baseDir.relativize(path);
return MorePaths.pathWithUnixSeparators(relativePath);
}
private CustomZipEntry getZipEntry(
String entryName, final Path path, BasicFileAttributes attr) throws IOException {
boolean isDirectory = filesystem.isDirectory(path);
if (isDirectory) {
entryName += "/";
}
CustomZipEntry entry = new CustomZipEntry(entryName);
// We want deterministic ZIPs, so avoid mtimes.
entry.setFakeTime();
entry.setCompressionLevel(
isDirectory
? ZipCompressionLevel.MIN_COMPRESSION_LEVEL.getValue()
: compressionLevel.getValue());
// If we're using STORED files, we must manually set the CRC, size, and compressed size.
if (entry.getMethod() == ZipEntry.STORED && !isDirectory) {
entry.setSize(attr.size());
entry.setCompressedSize(attr.size());
entry.setCrc(
new ByteSource() {
@Override
public InputStream openStream() throws IOException {
return filesystem.newFileInputStream(path);
}
}.hash(Hashing.crc32()).padToLong());
}
long externalAttributes = filesystem.getFileAttributesForZipEntry(path);
LOG.verbose(
"Setting mode for entry %s path %s to 0x%08X", entryName, path, externalAttributes);
entry.setExternalAttributes(externalAttributes);
return entry;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
if (!isSkipFile(file)) {
CustomZipEntry entry = getZipEntry(getEntryName(file), file, attrs);
entries.put(entry.getName(), new Pair<>(entry, Optional.of(file)));
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
throws IOException {
if (!dir.equals(baseDir) && !isSkipFile(dir)) {
CustomZipEntry entry = getZipEntry(getEntryName(dir), dir, attrs);
entries.put(entry.getName(), new Pair<>(entry, Optional.empty()));
}
return FileVisitResult.CONTINUE;
}
};
try (BufferedOutputStream baseOut =
new BufferedOutputStream(filesystem.newFileOutputStream(pathToZipFile));
CustomZipOutputStream out = ZipOutputStreams.newOutputStream(baseOut, THROW_EXCEPTION)) {
filesystem.walkRelativeFileTree(baseDir, pathFileVisitor);
// Write the entries out using the iteration order of the tree map above.
for (Pair<CustomZipEntry, Optional<Path>> entry : entries.values()) {
out.putNextEntry(entry.getFirst());
if (entry.getSecond().isPresent()) {
try (InputStream input = filesystem.newFileInputStream(entry.getSecond().get())) {
ByteStreams.copy(input, out);
}
}
out.closeEntry();
}
} catch (IOException e) {
context.logError(e, "Error creating zip file %s", pathToZipFile);
return StepExecutionResult.ERROR;
}
return StepExecutionResult.SUCCESS;
}
@Override
public String getDescription(ExecutionContext context) {
StringBuilder args = new StringBuilder("zip ");
// Don't add extra fields, neither do the Android tools.
args.append("-X ");
// recurse
args.append("-r ");
// compression level
args.append("-").append(compressionLevel).append(" ");
// unk paths
if (junkPaths) {
args.append("-j ");
}
// destination archive
args.append(pathToZipFile.toString()).append(" ");
// files to add to archive
if (paths.isEmpty()) {
// Add the contents of workingDirectory to archive.
args.append("-i* ");
args.append(". ");
} else {
// Add specified paths, relative to workingDirectory.
for (Path path : paths) {
args.append(path.toString()).append(" ");
}
}
return args.toString();
}
@Override
public String getShortName() {
return "zip";
}
}