Create a tool for building resource jars

Move the functionality for building resources jars out of JavaBuilder, in
preparation for building resource jars as a separate action.

--
PiperOrigin-RevId: 146086774
MOS_MIGRATED_REVID=146086774
diff --git a/src/java_tools/buildjar/BUILD b/src/java_tools/buildjar/BUILD
index 880c13d..b47b17c 100644
--- a/src/java_tools/buildjar/BUILD
+++ b/src/java_tools/buildjar/BUILD
@@ -43,6 +43,7 @@
         "//src/java_tools/buildjar/java/com/google/devtools/build/java/turbine:srcs",
         "//src/java_tools/buildjar/javatests/com/google/devtools/build/java/bazel:srcs",
         "//src/java_tools/buildjar/javatests/com/google/devtools/build/java/turbine:srcs",
+        "//src/java_tools/buildjar/javatests/com/google/devtools/build/buildjar/resourcejar:srcs",
     ],
     visibility = ["//src:__pkg__"],
 )
diff --git a/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/BUILD b/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/BUILD
index 5e948d2..d05bdee 100644
--- a/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/BUILD
+++ b/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/BUILD
@@ -179,6 +179,7 @@
     srcs = glob(["**"]) + [
         "//src/java_tools/buildjar/java/com/google/devtools/build/buildjar/genclass:srcs",
         "//src/java_tools/buildjar/java/com/google/devtools/build/buildjar/jarhelper:srcs",
+        "//src/java_tools/buildjar/java/com/google/devtools/build/buildjar/resourcejar:srcs",
         "//src/java_tools/buildjar/java/com/google/devtools/build/buildjar/javac/plugins:srcs",
     ],
     visibility = ["//src/java_tools/buildjar:__pkg__"],
diff --git a/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/resourcejar/BUILD b/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/resourcejar/BUILD
new file mode 100644
index 0000000..5ef3e21
--- /dev/null
+++ b/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/resourcejar/BUILD
@@ -0,0 +1,27 @@
+filegroup(
+    name = "srcs",
+    srcs = glob(["**"]),
+    visibility = ["//src/java_tools/buildjar/java/com/google/devtools/build/buildjar:__pkg__"],
+)
+
+java_binary(
+    name = "ResourceJarBuilder",
+    main_class = "com.google.devtools.build.buildjar.resourcejar.ResourceJarBuilder",
+    runtime_deps = [":resourcejar"],
+)
+
+java_library(
+    name = "resourcejar",
+    srcs = [
+        "ResourceJarBuilder.java",
+        "ResourceJarOptions.java",
+        "ResourceJarOptionsParser.java",
+    ],
+    visibility = [
+        "//src/java_tools/buildjar/javatests/com/google/devtools/build/buildjar/resourcejar:__pkg__",
+    ],
+    deps = [
+        "//src/java_tools/buildjar/java/com/google/devtools/build/buildjar/jarhelper",
+        "//third_party:guava",
+    ],
+)
diff --git a/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/resourcejar/ResourceJarBuilder.java b/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/resourcejar/ResourceJarBuilder.java
new file mode 100644
index 0000000..480f929
--- /dev/null
+++ b/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/resourcejar/ResourceJarBuilder.java
@@ -0,0 +1,170 @@
+// Copyright 2017 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.buildjar.resourcejar;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.buildjar.jarhelper.JarCreator;
+import java.io.Closeable;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Constructs a jar file of Java resources. */
+public class ResourceJarBuilder implements Closeable {
+
+  public static void main(String[] args) throws Exception {
+    build(ResourceJarOptionsParser.parse(Arrays.asList(args)));
+  }
+
+  public static void build(ResourceJarOptions options) throws Exception {
+    try (ResourceJarBuilder builder = new ResourceJarBuilder(options)) {
+      builder.build();
+    }
+  }
+
+  /** Cache of opened zip filesystems. */
+  private final Map<Path, FileSystem> filesystems = new HashMap<>();
+
+  private final ResourceJarOptions options;
+
+  private ResourceJarBuilder(ResourceJarOptions options) {
+    this.options = options;
+  }
+
+  public void build() throws IOException {
+    final JarCreator jar = new JarCreator(options.output());
+    jar.setNormalize(true);
+    jar.setCompression(true);
+
+    addResourceJars(jar, options.resourceJars());
+    jar.addRootEntries(options.classpathResources());
+    addResourceEntries(jar, options.resources());
+    addMessageEntries(jar, options.messages());
+
+    jar.execute();
+  }
+
+  private void addResourceJars(final JarCreator jar, ImmutableList<String> resourceJars)
+      throws IOException {
+    for (String resourceJar : resourceJars) {
+      for (final Path root : getJarFileSystem(Paths.get(resourceJar)).getRootDirectories()) {
+        Files.walkFileTree(
+            root,
+            new SimpleFileVisitor<Path>() {
+              @Override
+              public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
+                  throws IOException {
+                // TODO(b/28452451): omit directories entries from jar files
+                if (dir.getNameCount() > 0) {
+                  jar.addEntry(root.relativize(dir).toString(), dir);
+                }
+                return FileVisitResult.CONTINUE;
+              }
+
+              @Override
+              public FileVisitResult visitFile(Path path, BasicFileAttributes attrs)
+                  throws IOException {
+                jar.addEntry(root.relativize(path).toString(), path);
+                return FileVisitResult.CONTINUE;
+              }
+            });
+      }
+    }
+  }
+
+  /**
+   * Adds a collection of resource entries. Each entry is a string composed of a pair of parts
+   * separated by a colon ':'. The name of the resource comes from the second part, and the path to
+   * the resource comes from the whole string with the colon replaced by a slash '/'.
+   *
+   * <pre>
+   * prefix:name => (name, prefix/name)
+   * </pre>
+   */
+  private static void addResourceEntries(JarCreator jar, Collection<String> resources)
+      throws IOException {
+    for (String resource : resources) {
+      int colon = resource.indexOf(':');
+      if (colon < 0) {
+        throw new IOException("" + resource + ": Illegal resource entry.");
+      }
+      String prefix = resource.substring(0, colon);
+      String name = resource.substring(colon + 1);
+      String path = colon > 0 ? prefix + "/" + name : name;
+      addEntryWithParents(jar, name, path);
+    }
+  }
+
+  private static void addMessageEntries(JarCreator jar, List<String> messages) throws IOException {
+    for (String message : messages) {
+      int colon = message.indexOf(':');
+      if (colon < 0) {
+        throw new IOException("" + message + ": Illegal message entry.");
+      }
+      String prefix = message.substring(0, colon);
+      String name = message.substring(colon + 1);
+      String path = colon > 0 ? prefix + "/" + name : name;
+      File messageFile = new File(path);
+      // Ignore empty messages. They get written by the translation importer
+      // when there is no translation for a particular language.
+      if (messageFile.length() != 0L) {
+        addEntryWithParents(jar, name, path);
+      }
+    }
+  }
+
+  /**
+   * Adds an entry to the jar, making sure that all the parent dirs up to the base of {@code entry}
+   * are also added.
+   *
+   * @param entry the PathFragment of the entry going into the Jar file
+   * @param file the PathFragment of the input file for the entry
+   */
+  @VisibleForTesting
+  static void addEntryWithParents(JarCreator creator, String entry, String file) {
+    while ((entry != null) && creator.addEntry(entry, file)) {
+      entry = new File(entry).getParent();
+      file = new File(file).getParent();
+    }
+  }
+
+  private FileSystem getJarFileSystem(Path sourceJar) throws IOException {
+    FileSystem fs = filesystems.get(sourceJar);
+    if (fs == null) {
+      filesystems.put(sourceJar, fs = FileSystems.newFileSystem(sourceJar, null));
+    }
+    return fs;
+  }
+
+  @Override
+  public void close() throws IOException {
+    for (FileSystem fs : filesystems.values()) {
+      fs.close();
+    }
+  }
+}
diff --git a/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/resourcejar/ResourceJarOptions.java b/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/resourcejar/ResourceJarOptions.java
new file mode 100644
index 0000000..bf1afe4
--- /dev/null
+++ b/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/resourcejar/ResourceJarOptions.java
@@ -0,0 +1,113 @@
+// Copyright 2017 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.buildjar.resourcejar;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableList;
+
+/** Resource jar builder options. */
+public class ResourceJarOptions {
+  private final String output;
+  private final ImmutableList<String> messages;
+  private final ImmutableList<String> resources;
+  private final ImmutableList<String> resourceJars;
+  private final ImmutableList<String> classpathResources;
+
+  public ResourceJarOptions(
+      String output,
+      ImmutableList<String> messages,
+      ImmutableList<String> resources,
+      ImmutableList<String> resourceJars,
+      ImmutableList<String> classpathResources) {
+    this.output = checkNotNull(output);
+    this.messages = messages;
+    this.resources = resources;
+    this.resourceJars = resourceJars;
+    this.classpathResources = classpathResources;
+  }
+
+  public String output() {
+    return output;
+  }
+
+  /**
+   * Resources to include in the jar.
+   *
+   * <p>The format is {@code <prefix>:<name>}, where {@code <prefix>/<name>} is the path to the
+   * resource file, and {code <name>} is the relative name that will be used for the resource jar
+   * entry.
+   */
+  public ImmutableList<String> resources() {
+    return resources;
+  }
+
+  /** Message files to include in the resource jar. The format is the same as {@link #resources}. */
+  public ImmutableList<String> messages() {
+    return messages;
+  }
+
+  /** Jar files of resources to append to the resource jar. */
+  public ImmutableList<String> resourceJars() {
+    return resourceJars;
+  }
+
+  /** Files to include as top-level entries in the resource jar. */
+  public ImmutableList<String> classpathResources() {
+    return classpathResources;
+  }
+
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  /** A builder for {@link ResourceJarOptions}. */
+  public static class Builder {
+    private String output;
+    private ImmutableList<String> messages = ImmutableList.of();
+    private ImmutableList<String> resources = ImmutableList.of();
+    private ImmutableList<String> resourceJars = ImmutableList.of();
+    private ImmutableList<String> classpathResources = ImmutableList.of();
+
+    public ResourceJarOptions build() {
+      return new ResourceJarOptions(output, messages, resources, resourceJars, classpathResources);
+    }
+
+    public Builder setOutput(String output) {
+      this.output = checkNotNull(output);
+      return this;
+    }
+
+    public Builder setMessages(ImmutableList<String> messages) {
+      this.messages = checkNotNull(messages);
+      return this;
+    }
+
+    public Builder setResources(ImmutableList<String> resources) {
+      this.resources = checkNotNull(resources);
+      return this;
+    }
+
+    public Builder setResourceJars(ImmutableList<String> resourceJars) {
+      this.resourceJars = checkNotNull(resourceJars);
+      return this;
+    }
+
+    public Builder setClasspathResources(ImmutableList<String> classpathResources) {
+      this.classpathResources = checkNotNull(classpathResources);
+      return this;
+    }
+  }
+}
diff --git a/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/resourcejar/ResourceJarOptionsParser.java b/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/resourcejar/ResourceJarOptionsParser.java
new file mode 100644
index 0000000..f84b883
--- /dev/null
+++ b/src/java_tools/buildjar/java/com/google/devtools/build/buildjar/resourcejar/ResourceJarOptionsParser.java
@@ -0,0 +1,121 @@
+// Copyright 2017 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.buildjar.resourcejar;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import javax.annotation.Nullable;
+
+/** A command line options parser for {@link ResourceJarOptions}. */
+public class ResourceJarOptionsParser {
+
+  /**
+   * Parses command line options into {@link ResourceJarOptions}, expanding any {@code @params}
+   * files.
+   */
+  public static ResourceJarOptions parse(Iterable<String> args) throws IOException {
+    ResourceJarOptions.Builder builder = ResourceJarOptions.builder();
+    parse(builder, args);
+    return builder.build();
+  }
+
+  /**
+   * Parses command line options into a {@link ResourceJarOptions.Builder}, expanding any
+   * {@code @params} files.
+   */
+  public static void parse(ResourceJarOptions.Builder builder, Iterable<String> args)
+      throws IOException {
+    Deque<String> argumentDeque = new ArrayDeque<>();
+    expandParamsFiles(argumentDeque, args);
+    parse(builder, argumentDeque);
+  }
+
+  private static final Splitter ARG_SPLITTER =
+      Splitter.on(CharMatcher.breakingWhitespace()).omitEmptyStrings().trimResults();
+
+  /**
+   * Pre-processes an argument list, expanding arguments of the form {@code @filename} by reading
+   * the content of the file and appending whitespace-delimited options to {@code argumentDeque}.
+   */
+  private static void expandParamsFiles(Deque<String> argumentDeque, Iterable<String> args)
+      throws IOException {
+    for (String arg : args) {
+      if (arg.isEmpty()) {
+        continue;
+      }
+      if (arg.startsWith("@") && !arg.startsWith("@@")) {
+        Path paramsPath = Paths.get(arg.substring(1));
+        expandParamsFiles(
+            argumentDeque, ARG_SPLITTER.split(new String(Files.readAllBytes(paramsPath), UTF_8)));
+      } else {
+        argumentDeque.addLast(arg);
+      }
+    }
+  }
+
+  private static void parse(ResourceJarOptions.Builder builder, Deque<String> argumentDeque) {
+    while (!argumentDeque.isEmpty()) {
+      String next = argumentDeque.pollFirst();
+      switch (next) {
+        case "--output":
+          builder.setOutput(readOne(argumentDeque));
+          break;
+        case "--messages":
+          builder.setMessages(readList(argumentDeque));
+          break;
+        case "--resources":
+          builder.setResources(readList(argumentDeque));
+          break;
+        case "--resource_jars":
+          builder.setResourceJars(readList(argumentDeque));
+          break;
+        case "--classpath_resources":
+          builder.setClasspathResources(readList(argumentDeque));
+          break;
+        default:
+          if (next.isEmpty() && !argumentDeque.isEmpty()) {
+            throw new IllegalArgumentException("unknown option: " + next);
+          }
+      }
+    }
+  }
+
+  /** Returns the value of an option, or {@code null}. */
+  @Nullable
+  private static String readOne(Deque<String> argumentDeque) {
+    if (argumentDeque.isEmpty() || argumentDeque.peekFirst().startsWith("-")) {
+      return null;
+    }
+    return argumentDeque.pollFirst();
+  }
+
+  /** Returns a list of option values. */
+  private static ImmutableList<String> readList(Deque<String> argumentDeque) {
+    ImmutableList.Builder<String> result = ImmutableList.builder();
+    while (!argumentDeque.isEmpty() && !argumentDeque.peekFirst().startsWith("--")) {
+      result.add(argumentDeque.pollFirst());
+    }
+    return result.build();
+  }
+}
diff --git a/src/java_tools/buildjar/javatests/com/google/devtools/build/buildjar/resourcejar/BUILD b/src/java_tools/buildjar/javatests/com/google/devtools/build/buildjar/resourcejar/BUILD
new file mode 100644
index 0000000..a587c7c
--- /dev/null
+++ b/src/java_tools/buildjar/javatests/com/google/devtools/build/buildjar/resourcejar/BUILD
@@ -0,0 +1,17 @@
+filegroup(
+    name = "srcs",
+    srcs = glob(["**"]),
+    visibility = ["//src/java_tools/buildjar:__pkg__"],
+)
+
+java_test(
+    name = "ResourceJarBuilderTest",
+    srcs = ["ResourceJarBuilderTest.java"],
+    deps = [
+        "//src/java_tools/buildjar/java/com/google/devtools/build/buildjar/resourcejar",
+        "//third_party:guava",
+        "//third_party:junit4",
+        "//third_party:truth",
+        "//third_party/java/jdk/langtools:javac",
+    ],
+)
diff --git a/src/java_tools/buildjar/javatests/com/google/devtools/build/buildjar/resourcejar/ResourceJarBuilderTest.java b/src/java_tools/buildjar/javatests/com/google/devtools/build/buildjar/resourcejar/ResourceJarBuilderTest.java
new file mode 100644
index 0000000..8b89377
--- /dev/null
+++ b/src/java_tools/buildjar/javatests/com/google/devtools/build/buildjar/resourcejar/ResourceJarBuilderTest.java
@@ -0,0 +1,232 @@
+// Copyright 2017 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.buildjar.resourcejar;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.JarOutputStream;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** {@link com.google.devtools.build.buildjar.resourcejar.ResourceJarBuilder}Test. */
+@RunWith(JUnit4.class)
+public class ResourceJarBuilderTest {
+
+  @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  @Test
+  public void options() throws IOException {
+    ResourceJarOptions options =
+        ResourceJarOptionsParser.parse(
+            ImmutableList.of(
+                "--output",
+                "resource.jar",
+                "--messages",
+                "m1",
+                "m2",
+                "--resources",
+                "r1",
+                "r2",
+                "--resource_jars",
+                "rj1",
+                "rj2",
+                "--classpath_resources",
+                "cr1",
+                "cr2"));
+    assertThat(options.output()).isEqualTo("resource.jar");
+    assertThat(options.messages()).containsExactly("m1", "m2");
+    assertThat(options.resources()).containsExactly("r1", "r2");
+    assertThat(options.resourceJars()).containsExactly("rj1", "rj2");
+    assertThat(options.classpathResources()).containsExactly("cr1", "cr2");
+  }
+
+  @Test
+  public void resourceJars() throws Exception {
+    File output = temporaryFolder.newFile("resources.jar");
+
+    File jar1 = temporaryFolder.newFile("jar1.jar");
+    try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jar1))) {
+      jos.putNextEntry(new JarEntry("one/a.properties"));
+      jos.putNextEntry(new JarEntry("one/b.properties"));
+    }
+
+    File jar2 = temporaryFolder.newFile("jar2.jar");
+    try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jar2))) {
+      jos.putNextEntry(new JarEntry("two/c.properties"));
+      jos.putNextEntry(new JarEntry("two/d.properties"));
+    }
+
+    ResourceJarBuilder.build(
+        ResourceJarOptions.builder()
+            .setOutput(output.toString())
+            .setResourceJars(ImmutableList.of(jar1.toString(), jar2.toString()))
+            .build());
+
+    List<String> entries = new ArrayList<>();
+    try (JarFile jf = new JarFile(output)) {
+      Enumeration<JarEntry> jes = jf.entries();
+      while (jes.hasMoreElements()) {
+        entries.add(jes.nextElement().getName());
+      }
+    }
+
+    assertThat(entries)
+        .containsExactly(
+            "META-INF/",
+            "META-INF/MANIFEST.MF",
+            "one/",
+            "one/a.properties",
+            "one/b.properties",
+            "two/",
+            "two/c.properties",
+            "two/d.properties")
+        .inOrder();
+  }
+
+  @Test
+  public void resources() throws Exception {
+    File output = temporaryFolder.newFile("resources.jar");
+
+    Path root = temporaryFolder.newFolder().toPath();
+
+    Path r1 = root.resolve("one/a.properties");
+    Files.createDirectories(r1.getParent());
+    Files.write(r1, "hello".getBytes(UTF_8));
+
+    Path r2 = root.resolve("two/b.properties");
+    Files.createDirectories(r2.getParent());
+    Files.write(r2, "goodbye".getBytes(UTF_8));
+
+    ResourceJarBuilder.build(
+        ResourceJarOptions.builder()
+            .setOutput(output.toString())
+            .setResources(
+                ImmutableList.of(
+                    root + ":" + root.relativize(r1), root + ":" + root.relativize(r2)))
+            .build());
+
+    List<String> entries = new ArrayList<>();
+    try (JarFile jf = new JarFile(output)) {
+      Enumeration<JarEntry> jes = jf.entries();
+      while (jes.hasMoreElements()) {
+        entries.add(jes.nextElement().getName());
+      }
+    }
+
+    assertThat(entries)
+        .containsExactly(
+            "META-INF/",
+            "META-INF/MANIFEST.MF",
+            "one/",
+            "one/a.properties",
+            "two/",
+            "two/b.properties");
+  }
+
+  @Test
+  public void rootEntries() throws Exception {
+    File output = temporaryFolder.newFile("resources.jar");
+
+    Path root = temporaryFolder.newFolder().toPath();
+
+    Path r1 = root.resolve("one/a.properties");
+    Files.createDirectories(r1.getParent());
+    Files.write(r1, "hello".getBytes(UTF_8));
+
+    Path r2 = root.resolve("two/b.properties");
+    Files.createDirectories(r2.getParent());
+    Files.write(r2, "goodbye".getBytes(UTF_8));
+
+    ResourceJarBuilder.build(
+        ResourceJarOptions.builder()
+            .setOutput(output.toString())
+            .setClasspathResources(ImmutableList.of(r1.toString(), r2.toString()))
+            .build());
+
+    List<String> entries = new ArrayList<>();
+    try (JarFile jf = new JarFile(output)) {
+      Enumeration<JarEntry> jes = jf.entries();
+      while (jes.hasMoreElements()) {
+        entries.add(jes.nextElement().getName());
+      }
+    }
+
+    assertThat(entries)
+        .containsExactly("META-INF/", "META-INF/MANIFEST.MF", "a.properties", "b.properties");
+  }
+
+  @Test
+  public void messages() throws Exception {
+    File output = temporaryFolder.newFile("resources.jar");
+
+    Path root = temporaryFolder.newFolder().toPath();
+
+    Path r1 = root.resolve("one/a.xmb");
+    Files.createDirectories(r1.getParent());
+    Files.write(r1, "hello".getBytes(UTF_8));
+
+    Path r2 = root.resolve("two/b.xmb");
+    Files.createDirectories(r2.getParent());
+    Files.write(r2, "goodbye".getBytes(UTF_8));
+
+    // empty messages are omitted
+    Path r3 = root.resolve("three/c.xmb");
+    Files.createDirectories(r3.getParent());
+    Files.write(r3, new byte[0]);
+
+    ResourceJarBuilder.build(
+        ResourceJarOptions.builder()
+            .setOutput(output.toString())
+            .setMessages(
+                ImmutableList.of(
+                    root + ":" + root.relativize(r1),
+                    root + ":" + root.relativize(r2),
+                    root + ":" + root.relativize(r3)))
+            .build());
+
+    List<String> entries = new ArrayList<>();
+    try (JarFile jf = new JarFile(output)) {
+      Enumeration<JarEntry> jes = jf.entries();
+      while (jes.hasMoreElements()) {
+        entries.add(jes.nextElement().getName());
+      }
+    }
+
+    assertThat(entries)
+        .containsExactly(
+            "META-INF/", //
+            "META-INF/MANIFEST.MF",
+            "one/",
+            "one/a.xmb",
+            "two/",
+            "two/b.xmb")
+        .inOrder();
+  }
+}