aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/java_tools/singlejar/java/com/google/devtools/build/singlejar/ZipCombiner.java
blob: 8a4669e3373686b3224758e6cd8c493364505b3b (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
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
// 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.singlejar;

import static java.nio.charset.StandardCharsets.UTF_8;

import com.google.devtools.build.singlejar.ZipEntryFilter.CustomMergeStrategy;
import com.google.devtools.build.singlejar.ZipEntryFilter.StrategyCallback;
import com.google.devtools.build.zip.ExtraData;
import com.google.devtools.build.zip.ExtraDataList;
import com.google.devtools.build.zip.ZipFileEntry;
import com.google.devtools.build.zip.ZipFileEntry.Compression;
import com.google.devtools.build.zip.ZipReader;
import com.google.devtools.build.zip.ZipUtil;
import com.google.devtools.build.zip.ZipWriter;

import java.io.BufferedOutputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.zip.CRC32;
import java.util.zip.Deflater;
import java.util.zip.DeflaterInputStream;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;

import javax.annotation.Nullable;

/**
 * An object that combines multiple ZIP files into a single file. It only
 * supports a subset of the ZIP format, specifically:
 * <ul>
 *   <li>It only supports STORE and DEFLATE storage methods.</li>
 *   <li>It only supports 32-bit ZIP files.</li>
 * </ul>
 *
 * <p>These restrictions are also present in the JDK implementations
 * {@link java.util.jar.JarInputStream}, {@link java.util.zip.ZipInputStream},
 * though they are not documented there.
 *
 * <p>IMPORTANT NOTE: Callers must call {@link #finish()} or {@link #close()}
 * at the end of processing to ensure that the output buffers are flushed and
 * the ZIP file is complete.
 *
 * <p>This class performs only rudimentary data checking. If the input files
 * are damaged, the output will likely also be damaged.
 *
 * <p>Also see:
 * <a href="http://www.pkware.com/documents/casestudies/APPNOTE.TXT">ZIP format</a>
 */
public class ZipCombiner implements AutoCloseable {
  public static final Date DOS_EPOCH = new Date(ZipUtil.DOS_EPOCH);
  /**
   * Whether to compress or decompress entries.
   */
  public enum OutputMode {

    /**
     * Output entries using any method.
     */
    DONT_CARE,

    /**
     * Output all entries using DEFLATE method, except directory entries. It is always more
     * efficient to store directory entries uncompressed.
     */
    FORCE_DEFLATE,

    /**
     * Output all entries using STORED method.
     */
    FORCE_STORED,
  }

  /**
   * The type of action to take for a ZIP file entry.
   */
  private enum ActionType {

    /**
     * Skip the entry.
     */
    SKIP,

    /**
     * Copy the entry.
     */
    COPY,

    /**
     * Rename the entry.
     */
    RENAME,

    /**
     * Merge the entry.
     */
    MERGE;
  }

  /**
   * Encapsulates the action to take for a ZIP file entry along with optional details specific to
   * the action type. The minimum requirements per type are:
   * <ul>
   *    <li>SKIP: none.</li>
   *    <li>COPY: none.</li>
   *    <li>RENAME: newName.</li>
   *    <li>MERGE: strategy, mergeBuffer.</li>
   * </ul>
   *
   * <p>An action can be easily changed from one type to another by using
   * {@link EntryAction#EntryAction(ActionType, EntryAction)}.
   */
  private static final class EntryAction {
    private final ActionType type;
    @Nullable private final Date date;
    @Nullable private final String newName;
    @Nullable private final CustomMergeStrategy strategy;
    @Nullable private final ByteArrayOutputStream mergeBuffer;

    /**
     * Create an action of the specified type with no extra details.
     */
    public EntryAction(ActionType type) {
      this(type, null, null, null, null);
    }

    /**
     * Create a duplicate action with a different {@link ActionType}.
     */
    public EntryAction(ActionType type, EntryAction action) {
      this(type, action.getDate(), action.getNewName(), action.getStrategy(),
          action.getMergeBuffer());
    }

    /**
     * Create an action of the specified type and details.
     *
     * @param type the type of action
     * @param date the custom date to set on the entry
     * @param newName the custom name to create the entry as
     * @param strategy the {@link CustomMergeStrategy} to use for merging this entry
     * @param mergeBuffer the output stream to use for merge results
     */
    public EntryAction(ActionType type, Date date, String newName, CustomMergeStrategy strategy,
        ByteArrayOutputStream mergeBuffer) {
      checkArgument(type != ActionType.RENAME || newName != null,
          "NewName must not be null if the ActionType is RENAME.");
      checkArgument(type != ActionType.MERGE || strategy != null,
          "Strategy must not be null if the ActionType is MERGE.");
      checkArgument(type != ActionType.MERGE || mergeBuffer != null,
          "MergeBuffer must not be null if the ActionType is MERGE.");
      this.type = type;
      this.date = date;
      this.newName = newName;
      this.strategy = strategy;
      this.mergeBuffer = mergeBuffer;
    }

    /** Returns the type. */
    public ActionType getType() {
      return type;
    }

    /** Returns the date. */
    public Date getDate() {
      return date;
    }

    /** Returns the new name. */
    public String getNewName() {
      return newName;
    }

    /** Returns the strategy. */
    public CustomMergeStrategy getStrategy() {
      return strategy;
    }

    /** Returns the mergeBuffer. */
    public ByteArrayOutputStream getMergeBuffer() {
      return mergeBuffer;
    }
  }

  private final class FilterCallback implements StrategyCallback {
    private String filename;
    private final AtomicBoolean called = new AtomicBoolean();

    public void resetForFile(String filename) {
      this.filename = filename;
      this.called.set(false);
    }

    @Override public void skip() throws IOException {
      checkCall();
      actions.put(filename, new EntryAction(ActionType.SKIP));
    }

    @Override public void copy(Date date) throws IOException {
      checkCall();
      actions.put(filename, new EntryAction(ActionType.COPY, date, null, null, null));
    }

    @Override public void rename(String newName, Date date) throws IOException {
      checkCall();
      actions.put(filename, new EntryAction(ActionType.RENAME, date, newName, null, null));
    }

    @Override public void customMerge(Date date, CustomMergeStrategy strategy) throws IOException {
      checkCall();
      actions.put(filename, new EntryAction(ActionType.MERGE, date, null, strategy,
          new ByteArrayOutputStream()));
    }

    private void checkCall() {
      checkState(called.compareAndSet(false, true), "The callback was already called once.");
    }
  }

  /** Returns a {@link Deflater} for performing ZIP compression. */
  private static Deflater getDeflater() {
    return new Deflater(Deflater.DEFAULT_COMPRESSION, true);
  }

  /** Returns a {@link Inflater} for performing ZIP decompression. */
  private static Inflater getInflater() {
    return new Inflater(true);
  }

  /** Copies all data from the input stream to the output stream. */
  private static long copyStream(InputStream from, OutputStream to) throws IOException {
    byte[] buf = new byte[0x1000];
    long total = 0;
    int r;
    while ((r = from.read(buf)) != -1) {
      to.write(buf, 0, r);
      total += r;
    }
    return total;
  }

  private final OutputMode mode;
  private final ZipEntryFilter entryFilter;
  private final FilterCallback callback;
  private final ZipWriter out;

  private final Map<String, ZipFileEntry> entries;
  private final Map<String, EntryAction> actions;

  /**
   * Creates a {@link ZipCombiner} for combining ZIP files using the specified {@link OutputMode},
   * {@link ZipEntryFilter}, and destination {@link OutputStream}.
   *
   * @param mode the compression preference for the output ZIP file
   * @param entryFilter the filter to use when adding ZIP files to the combined output
   * @param out the {@link OutputStream} for writing the combined ZIP file
   */
  public ZipCombiner(OutputMode mode, ZipEntryFilter entryFilter, OutputStream out) {
    this.mode = mode;
    this.entryFilter = entryFilter;
    this.callback = new FilterCallback();
    this.out = new ZipWriter(new BufferedOutputStream(out), UTF_8);
    this.entries = new HashMap<>();
    this.actions = new HashMap<>();
  }

  /**
   * Creates a {@link ZipCombiner} for combining ZIP files using the specified
   * {@link ZipEntryFilter}, and destination {@link OutputStream}. Uses the DONT_CARE
   * {@link OutputMode}.
   *
   * @param entryFilter the filter to use when adding ZIP files to the combined output
   * @param out the {@link OutputStream} for writing the combined ZIP file
   */
  public ZipCombiner(ZipEntryFilter entryFilter, OutputStream out) {
    this(OutputMode.DONT_CARE, entryFilter, out);
  }

  /**
   * Creates a {@link ZipCombiner} for combining ZIP files using the specified {@link OutputMode},
   * and destination {@link OutputStream}. Uses a {@link CopyEntryFilter} as the
   * {@link ZipEntryFilter}.
   *
   * @param mode the compression preference for the output ZIP file
   * @param out the {@link OutputStream} for writing the combined ZIP file
   */
  public ZipCombiner(OutputMode mode, OutputStream out) {
    this(mode, new CopyEntryFilter(), out);
  }

  /**
   * Creates a {@link ZipCombiner} for combining ZIP files using the specified destination
   * {@link OutputStream}. Uses the DONT_CARE {@link OutputMode} and a {@link CopyEntryFilter} as
   * the {@link ZipEntryFilter}.
   *
   * @param out the {@link OutputStream} for writing the combined ZIP file
   */
  public ZipCombiner(OutputStream out) {
    this(OutputMode.DONT_CARE, new CopyEntryFilter(), out);
  }

  /**
   * Write all contents from the {@link InputStream} as a prefix file for the combined ZIP file.
   *
   * @param in the {@link InputStream} containing the prefix file data
   * @throws IOException if there is an error writing the prefix file
   */
  public void prependExecutable(InputStream in) throws IOException {
    out.startPrefixFile();
    copyStream(in, out);
    out.endPrefixFile();
  }

  /**
   * Adds a directory entry to the combined ZIP file using the specified filename and date.
   *
   * @param filename the name of the directory to create
   * @param date the modified time to assign to the directory
   * @throws IOException if there is an error writing the directory entry
   */
  public void addDirectory(String filename, Date date) throws IOException {
    addDirectory(filename, date, new ExtraData[0]);
  }

  /**
   * Adds a directory entry to the combined ZIP file using the specified filename, date, and extra
   * data.
   *
   * @param filename the name of the directory to create
   * @param date the modified time to assign to the directory
   * @param extra the extra field data to add to the directory entry
   * @throws IOException if there is an error writing the directory entry
   */
  public void addDirectory(String filename, Date date, ExtraData[] extra) throws IOException {
    checkArgument(filename.endsWith("/"), "Directory names must end with a /");
    checkState(!entries.containsKey(filename),
        "Zip already contains a directory named %s", filename);

    ZipFileEntry entry = new ZipFileEntry(filename);
    entry.setMethod(Compression.STORED);
    entry.setCrc(0);
    entry.setSize(0);
    entry.setCompressedSize(0);
    entry.setTime(date != null ? date.getTime() : new Date().getTime());
    entry.setExtra(new ExtraDataList(extra));
    out.putNextEntry(entry);
    out.closeEntry();
    entries.put(filename, entry);
  }

  /**
   * Adds a file with the specified name to the combined ZIP file.
   *
   * @param filename the name of the file to create
   * @param in the {@link InputStream} containing the file data
   * @throws IOException if there is an error writing the file entry
   * @throws IllegalArgumentException if the combined ZIP file already contains a file of the same
   *     name.
   */
  public void addFile(String filename, InputStream in) throws IOException {
    addFile(filename, null, in);
  }

  /**
   * Adds a file with the specified name and date to the combined ZIP file.
   *
   * @param filename the name of the file to create
   * @param date the modified time to assign to the file
   * @param in the {@link InputStream} containing the file data
   * @throws IOException if there is an error writing the file entry
   * @throws IllegalArgumentException if the combined ZIP file already contains a file of the same
   *     name.
   */
  public void addFile(String filename, Date date, InputStream in) throws IOException {
    ZipFileEntry entry = new ZipFileEntry(filename);
    entry.setTime(date != null ? date.getTime() : new Date().getTime());
    addFile(entry, in);
  }

  /**
   * Adds a file with attributes specified by the {@link ZipFileEntry} to the combined ZIP file.
   *
   * @param entry the {@link ZipFileEntry} containing the entry meta-data
   * @param in the {@link InputStream} containing the file data
   * @throws IOException if there is an error writing the file entry
   * @throws IllegalArgumentException if the combined ZIP file already contains a file of the same
   *     name.
   */
  public void addFile(ZipFileEntry entry, InputStream in) throws IOException {
    checkNotNull(entry, "Zip entry must not be null.");
    checkNotNull(in, "Input stream must not be null.");
    checkArgument(!entries.containsKey(entry.getName()), "Zip already contains a file named '%s'.",
        entry.getName());

    ByteArrayOutputStream uncompressed = new ByteArrayOutputStream();
    copyStream(in, uncompressed);

    writeEntryFromBuffer(new ZipFileEntry(entry), uncompressed.toByteArray());
  }

  /**
   * Adds the contents of a ZIP file to the combined ZIP file using the specified
   * {@link ZipEntryFilter} to determine the appropriate action for each file. 
   *
   * @param zipFile the ZIP file to add to the combined ZIP file
   * @throws IOException if there is an error reading the ZIP file or writing entries to the
   *     combined ZIP file
   */
  public void addZip(File zipFile) throws IOException {
    try (ZipReader zip = new ZipReader(zipFile)) {
      for (ZipFileEntry entry : zip.entries()) {
        String filename = entry.getName();
        EntryAction action = getAction(filename);
        switch (action.getType()) {
          case SKIP:
            break;
          case COPY:
          case RENAME:
            writeEntry(zip, entry, action);
            break;
          case MERGE:
            entries.put(filename, null);
            InputStream in = zip.getRawInputStream(entry);
            if (entry.getMethod() == Compression.DEFLATED) {
              in = new InflaterInputStream(in, getInflater());
            }
            action.getStrategy().merge(in, action.getMergeBuffer());
            break;
        }
      }
    }
  }

  /** Returns the action to take for a file of the given filename. */
  private EntryAction getAction(String filename) throws IOException {
    // If this filename has not been encountered before (no entry for filename) or this filename
    // has been renamed (RENAME entry for filename), the desired action should be recomputed.
    if (!actions.containsKey(filename) || actions.get(filename).getType() == ActionType.RENAME) {
      callback.resetForFile(filename);
      entryFilter.accept(filename, callback);
    }
    checkState(actions.containsKey(filename),
        "Action for file '%s' should have been set by ZipEntryFilter.", filename);

    EntryAction action = actions.get(filename);
    // Only copy if this is the first instance of filename.
    if (action.getType() == ActionType.COPY && entries.containsKey(filename)) {
      action = new EntryAction(ActionType.SKIP, action);
      actions.put(filename, action);
    }
    // Only rename if there is not already an entry with filename or filename's action is SKIP.
    if (action.getType() == ActionType.RENAME) {
      if (actions.containsKey(action.getNewName())
          && actions.get(action.getNewName()).getType() == ActionType.SKIP) {
        action = new EntryAction(ActionType.SKIP, action);
      }
      if (entries.containsKey(action.getNewName())) {
        action = new EntryAction(ActionType.SKIP, action);
      }
    }
    return action;
  }

  /** Writes an entry with the given name, date and external file attributes from the buffer. */
  private void writeEntryFromBuffer(ZipFileEntry entry, byte[] uncompressed) throws IOException {
    CRC32 crc = new CRC32();
    crc.update(uncompressed);

    entry.setCrc(crc.getValue());
    entry.setSize(uncompressed.length);
    if (mode == OutputMode.FORCE_STORED) {
      entry.setMethod(Compression.STORED);
      entry.setCompressedSize(uncompressed.length);
      writeEntry(entry, new ByteArrayInputStream(uncompressed));
    } else {
      ByteArrayOutputStream compressed = new ByteArrayOutputStream();
      copyStream(new DeflaterInputStream(new ByteArrayInputStream(uncompressed), getDeflater()),
          compressed);
      entry.setMethod(Compression.DEFLATED);
      entry.setCompressedSize(compressed.size());
      writeEntry(entry, new ByteArrayInputStream(compressed.toByteArray()));
    }
  }

  /**
   * Writes an entry from the specified source {@link ZipReader} and {@link ZipFileEntry} using the
   * specified {@link EntryAction}.
   * 
   *  <p>Writes the output entry from the input entry performing inflation or deflation as needed
   *  and applies any values from the {@link EntryAction} as needed.
   */
  private void writeEntry(ZipReader zip, ZipFileEntry entry, EntryAction action)
      throws IOException {
    checkArgument(action.getType() != ActionType.SKIP,
        "Cannot write a zip entry whose action is of type SKIP.");

    ZipFileEntry outEntry = new ZipFileEntry(entry);
    if (action.getType() == ActionType.RENAME) {
      checkNotNull(action.getNewName(),
          "ZipEntryFilter actions of type RENAME must not have a null filename.");
      outEntry.setName(action.getNewName());
    }

    if (action.getDate() != null) {
      outEntry.setTime(action.getDate().getTime());
    }

    InputStream data;
    if (mode == OutputMode.FORCE_DEFLATE && entry.getMethod() != Compression.DEFLATED) {
      // The output mode is deflate, but the entry compression is not. Create a deflater stream
      // from the raw file data and deflate to a temporary byte array to determine the deflated
      // size. Then use this byte array as the input stream for writing the entry.
      ByteArrayOutputStream tmp = new ByteArrayOutputStream();
      copyStream(new DeflaterInputStream(zip.getRawInputStream(entry), getDeflater()), tmp);
      data = new ByteArrayInputStream(tmp.toByteArray());
      outEntry.setMethod(Compression.DEFLATED);
      outEntry.setCompressedSize(tmp.size());
    } else if (mode == OutputMode.FORCE_STORED && entry.getMethod() != Compression.STORED) {
      // The output mode is stored, but the entry compression is not; create an inflater stream
      // from the raw file data. 
      data = new InflaterInputStream(zip.getRawInputStream(entry), getInflater());
      outEntry.setMethod(Compression.STORED);
      outEntry.setCompressedSize(entry.getSize());
    } else {
      // Entry compression agrees with output mode; use the raw file data as is.
      data = zip.getRawInputStream(entry);
    }
    writeEntry(outEntry, data);
  }

  /**
   * Writes the specified {@link ZipFileEntry} using the data from the given {@link InputStream}.
   */
  private void writeEntry(ZipFileEntry entry, InputStream data) throws IOException {
    out.putNextEntry(entry);
    copyStream(data, out);
    out.closeEntry();
    entries.put(entry.getName(), entry);
  }

  /**
   * Returns true if the combined ZIP file already contains a file of the specified file name.
   *
   * @param filename the filename of the file whose presence in the combined ZIP file is to be
   *     tested
   * @return true if the combined ZIP file contains the specified file
   */
  public boolean containsFile(String filename) {
    // TODO(apell): may be slightly different behavior because v1 returns true on skipped names.
    return entries.containsKey(filename);
  }

  /**
   * Writes any remaining output data to the output stream and also creates the merged entries by
   * calling the {@link CustomMergeStrategy} implementations given back from the
   * {@link ZipEntryFilter}.
   *
   * @throws IOException if the output stream or the filter throws an IOException
   * @throws IllegalStateException if this method was already called earlier
   */
  public void finish() throws IOException {
    for (Entry<String, EntryAction> entry : actions.entrySet()) {
      String filename = entry.getKey();
      EntryAction action = entry.getValue();
      if (action.getType() == ActionType.MERGE) {
        ByteArrayOutputStream uncompressed = action.getMergeBuffer();
        action.getStrategy().finish(uncompressed);

        ZipFileEntry e = new ZipFileEntry(filename);
        e.setTime(action.getDate() != null ? action.getDate().getTime() : new Date().getTime());
        writeEntryFromBuffer(e, uncompressed.toByteArray());
      }
    }
    out.finish();
  }

  /**
   * Writes any remaining output data to the output stream and closes it.
   *
   * @throws IOException if the output stream or the filter throws an IOException
   */
  @Override public void close() throws IOException {
    finish();
    out.close();
  }

  /** Ensures the truth of an expression involving one or more parameters to the calling method. */
  private static void checkArgument(boolean expression,
      @Nullable String errorMessageTemplate,
      @Nullable Object... errorMessageArgs) {
    if (!expression) {
      throw new IllegalArgumentException(String.format(errorMessageTemplate, errorMessageArgs));
    }
  }

  /** Ensures that an object reference passed as a parameter to the calling method is not null. */
  public static <T> T checkNotNull(T reference,
      @Nullable String errorMessageTemplate,
      @Nullable Object... errorMessageArgs) {
    if (reference == null) {
      // If either of these parameters is null, the right thing happens anyway
      throw new NullPointerException(String.format(errorMessageTemplate, errorMessageArgs));
    }
    return reference;
  }

  /** Ensures the truth of an expression involving state. */
  private static void checkState(boolean expression,
      @Nullable String errorMessageTemplate,
      @Nullable Object... errorMessageArgs) {
    if (!expression) {
      throw new IllegalStateException(String.format(errorMessageTemplate, errorMessageArgs));
    }
  }
}