aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/objc_tools/plmerge/java/com/google/devtools/build/xcode/plmerge/PlistMerging.java
blob: 31201eeb2bf0bb3243dd4b6c2bdd42be459c4eaf (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
// 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.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableBiMap;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import com.google.common.collect.Ordering;
import com.google.common.collect.Sets;
import com.google.common.io.ByteSource;
import com.google.devtools.build.xcode.common.Platform;
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 com.dd.plist.BinaryPropertyListWriter;
import com.dd.plist.NSArray;
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 org.xml.sax.SAXException;

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.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;

/**
 * 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";
  private static final ImmutableBiMap<String, Integer> DEVICE_FAMILIES =
      ImmutableBiMap.of("IPHONE", 1, "IPAD", 2);

  /**
   * 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;

  @VisibleForTesting
  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(Iterable<? 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 plist file.
   * @param plistPath the path of the plist to write in binary format
   */
  public void writePlist(Path plistPath) throws IOException {
    try (OutputStream out = Files.newOutputStream(plistPath)) {
      BinaryPropertyListWriter.write(out, merged);
    }
  }

  /**
   * 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 void 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));
  }

  /** Invokes {@link #writePlist(Path)} and {@link #writePkgInfo(Path)}. */
  public void write(Path plistPath, Path pkgInfoPath) throws IOException {
    writePlist(plistPath);
    writePkgInfo(pkgInfoPath);
  }

  /**
   * Returns a map containing entries that should be added to the merged plist. These are usually
   * generated by Xcode automatically during the build process.
   */
  public static Map<String, NSObject> automaticEntries(
      Collection<String> targetedDeviceFamily, Platform platform, String sdkVersion,
      String minimumOsVersion) {
    ImmutableMap.Builder<String, NSObject> result = new ImmutableMap.Builder<>();
    List<Integer> uiDeviceFamily = FluentIterable.from(targetedDeviceFamily)
        .transform(Maps.asConverter(DEVICE_FAMILIES))
        .toSortedList(Ordering.natural());

    result.put("UIDeviceFamily", NSObject.wrap(uiDeviceFamily.toArray()));
    result.put("DTPlatformName", NSObject.wrap(platform.getLowerCaseNameInPlist()));
    result.put("DTSDKName", NSObject.wrap(platform.getLowerCaseNameInPlist() + sdkVersion));
    result.put("CFBundleSupportedPlatforms", new NSArray(NSObject.wrap(platform.getNameInPlist())));
    result.put("MinimumOSVersion", NSObject.wrap(minimumOsVersion));

    return result.build();
  }

  /**
   * Generates a Plistmerging combining values from sourceFiles and automaticEntries, and modifying
   * them based on subsitutions and keysToRemoveIfEmptyString.
   */
  public static PlistMerging from(List<Path> sourceFiles, Map<String, NSObject> automaticEntries,
      Map<String, String> substitutions, KeysToRemoveIfEmptyString keysToRemoveIfEmptyString)
          throws IOException {
    NSDictionary merged = PlistMerging.merge(sourceFiles);

    Set<String> conflictingEntries = Sets.intersection(automaticEntries.keySet(), merged.keySet());

    Preconditions.checkArgument(conflictingEntries.isEmpty(),
        "The following plist entries are generated automatically, but are present in more than one "
            + "of the input lists: %s", conflictingEntries);
    merged.putAll(automaticEntries);

    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 automaticEntries
    // have any values set.
    // TODO(bazel-team): warn user if we replace their values.
    if (automaticEntries.size() > 0) {
      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);
        }
      }
    }
    return new PlistMerging(merged);
  }

  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;
    }
  }
}