blob: 091f446994b75e69cdc350ed3c4f8c9d945468c3 [file] [log] [blame]
// 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.plmerge;
import com.dd.plist.BinaryPropertyListWriter;
import com.dd.plist.NSDictionary;
import com.dd.plist.NSObject;
import com.dd.plist.NSString;
import com.dd.plist.PropertyListFormatException;
import com.dd.plist.PropertyListParser;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Sets;
import com.google.common.io.ByteSource;
import com.google.devtools.build.xcode.plmerge.proto.PlMergeProtos.Control;
import com.google.devtools.build.xcode.util.Equaling;
import com.google.devtools.build.xcode.util.Mapping;
import com.google.devtools.build.xcode.util.Value;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.ParseException;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import javax.xml.parsers.ParserConfigurationException;
import org.xml.sax.SAXException;
/**
* Utility code for merging project files.
*/
public class PlistMerging extends Value<PlistMerging> {
private static final String BUNDLE_IDENTIFIER_PLIST_KEY = "CFBundleIdentifier";
private static final String BUNDLE_IDENTIFIER_DEFAULT = "com.generic.bundleidentifier";
private static final String BUNDLE_VERSION_PLIST_KEY = "CFBundleVersion";
private static final String BUNDLE_VERSION_DEFAULT = "1.0.0";
private static final String BUNDLE_SHORT_VERSION_STRING_PLIST_KEY = "CFBundleShortVersionString";
private static final String BUNDLE_SHORT_VERSION_STRING_DEFAULT = "1.0";
/**
* Exception type thrown when validation of the plist file fails.
*/
public static class ValidationException extends RuntimeException {
ValidationException(String message) {
super(message);
}
}
private final NSDictionary merged;
/**
* Wraps a {@code NSDictionary} as a PlistMerging.
*/
public PlistMerging(NSDictionary merged) {
super(merged);
this.merged = merged;
}
/**
* Merges several plist files into a single {@code NSDictionary}. Each file should be a plist (of
* one of these formats: ASCII, Binary, or XML) that contains an NSDictionary.
*/
@VisibleForTesting
static NSDictionary merge(Collection<? extends Path> sourceFilePaths) throws IOException {
NSDictionary result = new NSDictionary();
for (Path sourceFilePath : sourceFilePaths) {
result.putAll(readPlistFile(sourceFilePath));
}
return result;
}
public static NSDictionary readPlistFile(final Path sourceFilePath) throws IOException {
ByteSource rawBytes = new Utf8BomSkippingByteSource(sourceFilePath);
try {
try (InputStream in = rawBytes.openStream()) {
return (NSDictionary) PropertyListParser.parse(in);
} catch (PropertyListFormatException | ParseException e) {
// If we failed to parse, the plist may implicitly be a map. To handle this, wrap the plist
// with {}.
// TODO(bazel-team): Do this in a cleaner way.
ByteSource concatenated = ByteSource.concat(
ByteSource.wrap(new byte[] {'{'}),
rawBytes,
ByteSource.wrap(new byte[] {'}'}));
try (InputStream in = concatenated.openStream()) {
return (NSDictionary) PropertyListParser.parse(in);
}
}
} catch (PropertyListFormatException | ParseException | ParserConfigurationException
| SAXException e) {
throw new IOException(e);
}
}
/**
* Writes the results of a merge operation to a binary plist file.
* @param plistPath the path of the plist to write in binary format
*/
public PlistMerging writePlist(Path plistPath) throws IOException {
try (OutputStream out = Files.newOutputStream(plistPath)) {
BinaryPropertyListWriter.write(out, merged);
}
return this;
}
/**
* Writes the results of a merge operation to an XML plist file.
* @param plistPath the path of the plist to write in XML format
*/
public PlistMerging writeXmlPlist(Path plistPath) throws IOException {
try (OutputStream out = Files.newOutputStream(plistPath)) {
PropertyListParser.saveAsXML(merged, out);
}
return this;
}
/**
* Writes a PkgInfo file based on certain keys in the merged plist.
* @param pkgInfoPath the path of the PkgInfo file to write. In many iOS apps, this file just
* contains the raw string {@code APPL????}.
*/
public PlistMerging writePkgInfo(Path pkgInfoPath) throws IOException {
String pkgInfo =
Mapping.of(merged, "CFBundlePackageType").or(NSObject.wrap("APPL")).toString()
+ Mapping.of(merged, "CFBundleSignature").or(NSObject.wrap("????")).toString();
Files.write(pkgInfoPath, pkgInfo.getBytes(StandardCharsets.UTF_8));
return this;
}
/**
* Generates a Plistmerging combining values from sourceFiles and immutableSourceFiles, and
* modifying them based on substitutions and keysToRemoveIfEmptyString.
*/
public static PlistMerging from(
Control control,
KeysToRemoveIfEmptyString keysToRemoveIfEmptyString)
throws IOException {
FileSystem fileSystem = FileSystems.getDefault();
ImmutableList.Builder<Path> sourceFilePathsBuilder = new ImmutableList.Builder<>();
for (String pathString : control.getSourceFileList()) {
sourceFilePathsBuilder.add(fileSystem.getPath(pathString));
}
ImmutableList.Builder<Path> immutableSourceFilePathsBuilder = new ImmutableList.Builder<>();
for (String pathString : control.getImmutableSourceFileList()) {
immutableSourceFilePathsBuilder.add(fileSystem.getPath(pathString));
}
return from(
sourceFilePathsBuilder.build(),
immutableSourceFilePathsBuilder.build(),
control.getVariableSubstitutionMap(),
keysToRemoveIfEmptyString,
Strings.emptyToNull(control.getExecutableName()));
}
/**
* Generates a Plistmerging combining values from sourceFiles and immutableSourceFiles, and
* modifying them based on subsitutions and keysToRemoveIfEmptyString.
*/
public static PlistMerging from(
List<Path> sourceFiles,
List<Path> immutableSourceFiles,
Map<String, String> substitutions,
KeysToRemoveIfEmptyString keysToRemoveIfEmptyString,
String executableName)
throws IOException {
NSDictionary merged = PlistMerging.merge(sourceFiles);
NSDictionary immutableEntries = PlistMerging.merge(immutableSourceFiles);
Set<String> conflictingEntries = Sets.intersection(immutableEntries.keySet(), merged.keySet());
Preconditions.checkArgument(
conflictingEntries.isEmpty(),
"The following plist entries may not be overridden, but are present in more than one "
+ "of the input lists: %s",
conflictingEntries);
merged.putAll(immutableEntries);
for (Map.Entry<String, NSObject> entry : merged.entrySet()) {
if (entry.getValue().toJavaObject() instanceof String) {
String newValue = substituteEnvironmentVariable(
substitutions, (String) entry.getValue().toJavaObject());
merged.put(entry.getKey(), newValue);
}
}
for (String key : keysToRemoveIfEmptyString) {
if (Equaling.of(Mapping.of(merged, key), Optional.<NSObject>of(new NSString("")))) {
merged.remove(key);
}
}
// Info.plist files must contain a valid CFBundleVersion and a valid CFBundleShortVersionString,
// or it will be rejected by Apple.
// A valid Bundle Version is 18 characters or less, and only contains [0-9.]
// We know we have an info.plist file as opposed to a strings file if the immutableEntries
// have any values set.
// TODO(bazel-team): warn user if we replace their values.
if (!immutableEntries.isEmpty()) {
Pattern versionPattern = Pattern.compile("[^0-9.]");
if (!merged.containsKey(BUNDLE_VERSION_PLIST_KEY)) {
merged.put(BUNDLE_VERSION_PLIST_KEY, BUNDLE_VERSION_DEFAULT);
} else {
NSObject nsVersion = merged.get(BUNDLE_VERSION_PLIST_KEY);
String version = (String) nsVersion.toJavaObject();
if (version.length() > 18 || versionPattern.matcher(version).find()) {
merged.put(BUNDLE_VERSION_PLIST_KEY, BUNDLE_VERSION_DEFAULT);
}
}
if (!merged.containsKey(BUNDLE_SHORT_VERSION_STRING_PLIST_KEY)) {
merged.put(BUNDLE_SHORT_VERSION_STRING_PLIST_KEY, BUNDLE_SHORT_VERSION_STRING_DEFAULT);
} else {
NSObject nsVersion = merged.get(BUNDLE_SHORT_VERSION_STRING_PLIST_KEY);
String version = (String) nsVersion.toJavaObject();
if (version.length() > 18 || versionPattern.matcher(version).find()) {
merged.put(BUNDLE_SHORT_VERSION_STRING_PLIST_KEY, BUNDLE_SHORT_VERSION_STRING_DEFAULT);
}
}
}
PlistMerging result = new PlistMerging(merged);
if (executableName != null) {
result.setExecutableName(executableName);
}
return result;
}
private static String substituteEnvironmentVariable(
Map<String, String> substitutions, String string) {
// The substitution is *not* performed recursively.
for (Map.Entry<String, String> variable : substitutions.entrySet()) {
String key = variable.getKey();
String value = variable.getValue();
string = string
.replace("${" + key + "}", value)
.replace("$(" + key + ")", value);
key = key + ":rfc1034identifier";
value = convertToRFC1034(value);
string = string
.replace("${" + key + "}", value)
.replace("$(" + key + ")", value);
}
return string;
}
// Force RFC1034 compliance by changing any "bad" character to a '-'
// This is essentially equivalent to what Xcode does.
private static String convertToRFC1034(String value) {
return value.replaceAll("[^-0-9A-Za-z.]", "-");
}
@VisibleForTesting
NSDictionary asDictionary() {
return merged;
}
/**
* Sets the given executable name on this merged plist in the {@code CFBundleExecutable}
* attribute.
*
* @param executableName name of the bundle executable
* @return this plist merging
* @throws ValidationException if the plist already contains an incompatible
* {@code CFBundleExecutable} entry
*/
public PlistMerging setExecutableName(String executableName) {
NSString bundleExecutable = (NSString) merged.get("CFBundleExecutable");
if (bundleExecutable == null) {
merged.put("CFBundleExecutable", executableName);
} else if (!executableName.equals(bundleExecutable.getContent())) {
throw new ValidationException(String.format(
"Blaze generated the executable %s but the Plist CFBundleExecutable is %s",
executableName, bundleExecutable));
}
return this;
}
/**
* Sets the given identifier on this merged plist in the {@code CFBundleIdentifier}
* attribute.
*
* @param primaryIdentifier used to set the bundle identifier or override the existing one from
* plist file, can be null
* @param fallbackIdentifier used to set the bundle identifier if it is not set by plist file or
* primary identifier, can be null
* @return this plist merging
*/
public PlistMerging setBundleIdentifier(String primaryIdentifier, String fallbackIdentifier) {
NSString bundleIdentifier = (NSString) merged.get(BUNDLE_IDENTIFIER_PLIST_KEY);
if (primaryIdentifier != null) {
merged.put(BUNDLE_IDENTIFIER_PLIST_KEY, convertToRFC1034(primaryIdentifier));
} else if (bundleIdentifier == null) {
if (fallbackIdentifier != null) {
merged.put(BUNDLE_IDENTIFIER_PLIST_KEY, convertToRFC1034(fallbackIdentifier));
} else {
// TODO(bazel-team): We shouldn't be generating an info.plist in this case.
merged.put(BUNDLE_IDENTIFIER_PLIST_KEY, BUNDLE_IDENTIFIER_DEFAULT);
}
}
return this;
}
private static class Utf8BomSkippingByteSource extends ByteSource {
private static final byte[] UTF8_BOM =
new byte[] { (byte) 0xEF, (byte) 0xBB, (byte) 0xBF };
private final Path path;
public Utf8BomSkippingByteSource(Path path) {
this.path = path;
}
@Override
public InputStream openStream() throws IOException {
InputStream stream = new BufferedInputStream(Files.newInputStream(path));
stream.mark(UTF8_BOM.length);
byte[] buffer = new byte[UTF8_BOM.length];
int read = stream.read(buffer);
stream.reset();
if (UTF8_BOM.length == read && Arrays.equals(buffer, UTF8_BOM)) {
stream.skip(UTF8_BOM.length);
}
return stream;
}
}
}