// Copyright 2014 The Bazel Authors. All rights reserved. // // 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.google.devtools.build.xcode.bundlemerge; import static com.google.devtools.build.singlejar.ZipCombiner.DOS_EPOCH; import static com.google.devtools.build.singlejar.ZipCombiner.OutputMode.FORCE_DEFLATE; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import com.google.devtools.build.singlejar.ZipCombiner; import com.google.devtools.build.xcode.bundlemerge.proto.BundleMergeProtos.BundleFile; import com.google.devtools.build.xcode.bundlemerge.proto.BundleMergeProtos.Control; import com.google.devtools.build.xcode.bundlemerge.proto.BundleMergeProtos.MergeZip; import com.google.devtools.build.xcode.plmerge.PlistMerging; import com.google.devtools.build.xcode.zip.ZipFiles; import com.google.devtools.build.xcode.zip.ZipInputEntry; import com.google.devtools.build.zip.ZipFileEntry; import java.io.IOException; import java.io.OutputStream; import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import javax.annotation.CheckReturnValue; /** * Implementation of the final steps to create an iOS application bundle. * * <p>TODO(bazel-team): Add asset catalog compilation and bundling to this logic. */ public final class BundleMerging { @VisibleForTesting final FileSystem fileSystem; @VisibleForTesting final Path outputZip; @VisibleForTesting final ImmutableList<ZipInputEntry> inputs; @VisibleForTesting final ImmutableList<MergeZip> mergeZips; /** * We can instantiate this class for testing purposes. For typical uses, just use * {@link #merge(FileSystem, Control)}. */ private BundleMerging(FileSystem fileSystem, Path outputZip, ImmutableList<ZipInputEntry> inputs, ImmutableList<MergeZip> mergeZips) { this.fileSystem = Preconditions.checkNotNull(fileSystem); this.outputZip = Preconditions.checkNotNull(outputZip); this.inputs = Preconditions.checkNotNull(inputs); this.mergeZips = Preconditions.checkNotNull(mergeZips); } /** * Joins two paths to be used in a zip file. The {@code right} part of the path must be relative. * The {@code left} part could or could not have a trailing slash. These paths are used in .ipa * (.zip) files, which must use forward slashes, so they are hard-coded here. * <p> * TODO(bazel-team): This is messy. See if we can use some common joining function that handles * empty paths and doesn't automatically inherit the path conventions of the host platform. */ private static String joinPath(String left, String right) { Preconditions.checkArgument(!right.startsWith("/"), "'right' must be relative: %s", right); if (left.isEmpty() || right.isEmpty() || left.endsWith("/")) { return left + right; } else { return left + "/" + right; } } private static final String INFOPLIST_FILENAME = "Info.plist"; private static final String PKGINFO_FILENAME = "PkgInfo"; /** * Adds merge artifacts from the given {@code control} into builders that collect merge zips and * individual files. {@code bundleRoot} is prepended to each path, except the paths in the merge * zips. */ private static void mergeInto( Path tempDir, FileSystem fileSystem, Control control, String bundleRoot, ImmutableList.Builder<ZipInputEntry> packagedFilesBuilder, ImmutableList.Builder<MergeZip> mergeZipsBuilder, boolean includePkgInfo) throws IOException { bundleRoot = joinPath(bundleRoot, control.getBundleRoot()); if (control.hasBundleInfoPlistFile()) { Path tempMergedPlist = Files.createTempFile(tempDir, null, INFOPLIST_FILENAME); Path tempPkgInfo = Files.createTempFile(tempDir, null, PKGINFO_FILENAME); Path bundleInfoPlist = fileSystem.getPath(control.getBundleInfoPlistFile()); new PlistMerging(PlistMerging.readPlistFile(bundleInfoPlist)) .setBundleIdentifier( control.hasPrimaryBundleIdentifier() ? control.getPrimaryBundleIdentifier() : null, control.hasFallbackBundleIdentifier() ? control.getFallbackBundleIdentifier() : null) .writePlist(tempMergedPlist) .writePkgInfo(tempPkgInfo); packagedFilesBuilder .add(new ZipInputEntry(tempMergedPlist, joinPath(bundleRoot, INFOPLIST_FILENAME))); if (includePkgInfo) { packagedFilesBuilder .add(new ZipInputEntry(tempPkgInfo, joinPath(bundleRoot, PKGINFO_FILENAME))); } } for (BundleFile bundleFile : control.getBundleFileList()) { int externalFileAttribute = bundleFile.hasExternalFileAttribute() ? bundleFile.getExternalFileAttribute() : ZipInputEntry.DEFAULT_EXTERNAL_FILE_ATTRIBUTE; packagedFilesBuilder.add( new ZipInputEntry( fileSystem.getPath(bundleFile.getSourceFile()), joinPath(bundleRoot, bundleFile.getBundlePath()), externalFileAttribute)); } mergeZipsBuilder.addAll(control.getMergeZipList()); for (Control nestedControl : control.getNestedBundleList()) { mergeInto(tempDir, fileSystem, nestedControl, bundleRoot, packagedFilesBuilder, mergeZipsBuilder, /*includePkgInfo=*/false); } } /** * Returns a zipper configuration that can be executed to create the application bundle. */ @CheckReturnValue @VisibleForTesting static BundleMerging merging(Path tempDir, FileSystem fileSystem, Control control) throws IOException { ImmutableList.Builder<MergeZip> mergeZipsBuilder = new ImmutableList.Builder<>(); ImmutableList.Builder<ZipInputEntry> packagedFilesBuilder = new ImmutableList.Builder<>(); mergeInto(tempDir, fileSystem, control, /*bundleRoot=*/"", packagedFilesBuilder, mergeZipsBuilder, /*includePkgInfo=*/true); return new BundleMerging(fileSystem, fileSystem.getPath(control.getOutFile()), packagedFilesBuilder.build(), mergeZipsBuilder.build()); } /** * Copies all entries from the source zip into a destination zip using the given combiner. The * contents of the source zip can appear to be in a sub-directory of the destination zip by * passing a non-empty string for the entry names prefix with a trailing '/'. */ private void addEntriesFromOtherZip(ZipCombiner combiner, Path sourceZip, String entryNamesPrefix) throws IOException { Map<String, Integer> externalFileAttributes = ZipFiles.unixExternalFileAttributes(sourceZip); try (ZipInputStream zipIn = new ZipInputStream(Files.newInputStream(sourceZip))) { while (true) { ZipEntry zipInEntry = zipIn.getNextEntry(); if (zipInEntry == null) { break; } // TODO(bazel-dev): Add support for soft links because we will need them for MacOS support // in frameworks at the very least. https://github.com/bazelbuild/bazel/issues/289 String name = entryNamesPrefix + zipInEntry.getName(); if (zipInEntry.isDirectory()) { // If we already have a directory entry with this name then don't attempt to // add it again. It's not an error to attempt to merge in two zip files that contain // the same directories. It's only an error to attempt to merge in two zip files with the // same leaf files. if (!combiner.containsFile(name)) { combiner.addDirectory(name, DOS_EPOCH); } continue; } Integer externalFileAttr = externalFileAttributes.get(zipInEntry.getName()); if (externalFileAttr == null) { externalFileAttr = ZipInputEntry.DEFAULT_EXTERNAL_FILE_ATTRIBUTE; } ZipFileEntry zipOutEntry = new ZipFileEntry(name); zipOutEntry.setTime(DOS_EPOCH.getTime()); zipOutEntry.setVersion(ZipInputEntry.MADE_BY_VERSION); zipOutEntry.setExternalAttributes(externalFileAttr); combiner.addFile(zipOutEntry, zipIn); } } } @VisibleForTesting void execute() throws IOException { try (OutputStream out = Files.newOutputStream(outputZip); ZipCombiner combiner = new ZipCombiner(FORCE_DEFLATE, out)) { ZipInputEntry.addAll(combiner, inputs); for (MergeZip mergeZip : mergeZips) { addEntriesFromOtherZip( combiner, fileSystem.getPath(mergeZip.getSourcePath()), mergeZip.getEntryNamePrefix()); } } } /** * Creates an {@code .ipa} file for an iOS application. * @param fileSystem used to resolve paths specified in {@code control} * @param control specifies the locations of input and output files and other parameters used to * create the final {@code .ipa} file. */ public static void merge(FileSystem fileSystem, Control control) throws IOException { merging(Files.createTempDirectory("mergebundle"), fileSystem, control).execute(); } }